From 5e22d55c833676bdda7919070686b51e7748ab33 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 12 Jul 2024 00:31:12 +0800 Subject: [PATCH 01/28] Add the Session.virtualfile_from_stringio function to support StringIO object as inputs --- pygmt/clib/session.py | 80 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index 7abe9b77e91..7c06dc720b6 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -7,6 +7,7 @@ import contextlib import ctypes as ctp +import io import pathlib import sys import warnings @@ -63,6 +64,7 @@ "GMT_IS_PLP", # items could be any one of POINT, LINE, or POLY "GMT_IS_SURFACE", # items are 2-D grid "GMT_IS_VOLUME", # items are 3-D grid + "GMT_IS_TEXT", # Text strings which triggers ASCII text reading ] METHODS = [ @@ -73,6 +75,11 @@ DIRECTIONS = ["GMT_IN", "GMT_OUT"] MODES = ["GMT_CONTAINER_ONLY", "GMT_IS_OUTPUT"] +MODE_MODIFIERS = [ + "GMT_GRID_IS_CARTESIAN", + "GMT_GRID_IS_GEO", + "GMT_WITH_STRINGS", +] REGISTRATIONS = ["GMT_GRID_PIXEL_REG", "GMT_GRID_NODE_REG"] @@ -747,7 +754,7 @@ def create_data( mode_int = self._parse_constant( mode, valid=MODES, - valid_modifiers=["GMT_GRID_IS_CARTESIAN", "GMT_GRID_IS_GEO"], + valid_modifiers=MODE_MODIFIERS, ) geometry_int = self._parse_constant(geometry, valid=GEOMETRIES) registration_int = self._parse_constant(registration, valid=REGISTRATIONS) @@ -1528,6 +1535,77 @@ def virtualfile_from_grid(self, grid): with self.open_virtualfile(*args) as vfile: yield vfile + @contextlib.contextmanager + def virtualfile_from_stringio(self, stringio: io.StringIO): + r""" + Store a StringIO object in a virtual file. + + Store the contents of a StringIO object in a GMT_DATASET container and + create a virtual file to pass to a GMT module. + + Parameters + ---------- + stringio + The StringIO object containing the data to be stored in the virtual + file. + + Yields + ------ + fname + The name of the virtual file. Pass this as a file name argument to a + GMT module. + + Examples + -------- + >>> import io + >>> from pygmt.clib import Session + >>> stringio = io.StringIO( + ... "H 24p Legend\nN 2\nS 0.1i c 0.15i p300/12 0.25p 0.3i My circle" + ... ) + >>> with Session() as lib: + ... with lib.virtualfile_from_stringio(stringio) as fin: + ... lib.call_module("legend", [fin, "-Dx0/0+w5c/5c"]) + """ + # Parse the strings in the StringIO object. + # For simplicity, we make a few assumptions. + # - "#" indicates a comment line + # - ">" indicates a segment header + # - Only one table and one segment + header = None + string_arrays = [] + for line in stringio.getvalue().splitlines(): + if line.startswith("#"): # Skip comments + continue + if line.startswith(">"): # Segment header + if header is not None: # Only one segment is allowed now. + raise GMTInvalidInput("Only one segment is allowed.") + header = line + continue + string_arrays.append(line) + # Only one table and one segment. No numeric data, so n_columns is 0. + n_tables, n_segments, n_rows, n_columns = 1, 1, len(string_arrays), 0 + + family, geometry = "GMT_IS_DATASET", "GMT_IS_TEXT" + dataset = self.create_data( + family, + geometry, + mode="GMT_CONTAINER_ONLY|GMT_WITH_STRINGS", + dim=[n_tables, n_segments, n_rows, n_columns], + ) + dataset = ctp.cast(dataset, ctp.POINTER(_GMT_DATASET)) + # Assign the strings to the segment + seg = dataset.contents.table[0].contents.segment[0].contents + if header is not None: + seg.header = header.encode() + seg.text = strings_to_ctypes_array(string_arrays) + + with self.open_virtualfile(family, geometry, "GMT_IN", dataset) as vfile: + try: + yield vfile + finally: + # Must set the text to None to avoid double freeing the memory + seg.text = None + def virtualfile_in( # noqa: PLR0912 self, check_kind=None, From f75844ef0182a7d47cf4906216e3165cea4b2bad Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 12 Jul 2024 00:51:47 +0800 Subject: [PATCH 02/28] Let virtualfile_in support the stringio kind --- pygmt/clib/session.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index 7c06dc720b6..1a452e48075 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1692,6 +1692,7 @@ def virtualfile_in( # noqa: PLR0912 "geojson": tempfile_from_geojson, "grid": self.virtualfile_from_grid, "image": tempfile_from_image, + "stringio": self.virtualfile_from_stringio, # Note: virtualfile_from_matrix is not used because a matrix can be # converted to vectors instead, and using vectors allows for better # handling of string type inputs (e.g. for datetime data types) @@ -1700,7 +1701,7 @@ def virtualfile_in( # noqa: PLR0912 }[kind] # Ensure the data is an iterable (Python list or tuple) - if kind in {"geojson", "grid", "image", "file", "arg"}: + if kind in {"geojson", "grid", "image", "file", "arg", "stringio"}: if kind == "image" and data.dtype != "uint8": msg = ( f"Input image has dtype: {data.dtype} which is unsupported, " From 50467b5f7c55cd05d578cf2662ea03867750f92e Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 12 Jul 2024 00:54:39 +0800 Subject: [PATCH 03/28] Make data_kind support stringio --- pygmt/helpers/utils.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pygmt/helpers/utils.py b/pygmt/helpers/utils.py index 8997a2b0df1..1c4e41c90f6 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 io import os import pathlib import shutil @@ -112,7 +113,7 @@ def _validate_data_input( def data_kind(data=None, x=None, y=None, z=None, required_z=False, required_data=True): - """ + r""" Check what kind of data is provided to a module. Possible types: @@ -129,7 +130,7 @@ def data_kind(data=None, x=None, y=None, z=None, required_z=False, required_data Parameters ---------- - data : str, pathlib.PurePath, None, bool, xarray.DataArray or {table-like} + data : str, io.StringIO, pathlib.PurePath, None, bool, xarray.DataArray or {table-like} Pass in either a file name or :class:`pathlib.Path` to an ASCII data table, an :class:`xarray.DataArray`, a 1-D/2-D {table-classes} or an option argument. @@ -147,7 +148,7 @@ def data_kind(data=None, x=None, y=None, z=None, required_z=False, required_data ------- kind : str One of ``'arg'``, ``'file'``, ``'grid'``, ``image``, ``'geojson'``, - ``'matrix'``, or ``'vectors'``. + ``'matrix'``, or ``'stringio'``, ``'vectors'``. Examples -------- @@ -155,6 +156,7 @@ def data_kind(data=None, x=None, y=None, z=None, required_z=False, required_data >>> import numpy as np >>> import xarray as xr >>> import pathlib + >>> import io >>> 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) @@ -173,7 +175,9 @@ def data_kind(data=None, x=None, y=None, z=None, required_z=False, required_data 'grid' >>> data_kind(data=xr.DataArray(np.random.rand(3, 4, 5))) 'image' - """ + >>> data_kind(data=io.StringIO("TEXT1\nTEXT23\n")) + 'stringio' + """ # noqa: W505 # determine the data kind if isinstance(data, str | pathlib.PurePath) or ( isinstance(data, list | tuple) @@ -183,6 +187,8 @@ def data_kind(data=None, x=None, y=None, z=None, required_z=False, required_data kind = "file" elif isinstance(data, bool | int | float) or (data is None and not required_data): kind = "arg" + elif isinstance(data, io.StringIO): + kind = "stringio" elif isinstance(data, xr.DataArray): kind = "image" if len(data.dims) == 3 else "grid" elif hasattr(data, "__geo_interface__"): From 14a14e92729716160435d959f8e7e99b6a7ccd80 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 12 Jul 2024 00:57:46 +0800 Subject: [PATCH 04/28] Refactor Figure.legend to support stringio --- pygmt/src/legend.py | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index e5a7ebefab0..3da276ded0d 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -2,6 +2,9 @@ legend - Plot a legend. """ +import io +import pathlib + from pygmt.clib import Session from pygmt.exceptions import GMTInvalidInput from pygmt.helpers import ( @@ -26,7 +29,13 @@ t="transparency", ) @kwargs_to_strings(R="sequence", c="sequence_comma", p="sequence") -def legend(self, spec=None, position="JTR+jTR+o0.2c", box="+gwhite+p1p", **kwargs): +def legend( + self, + spec: str | pathlib.PurePath | io.StringIO | None = None, + position="JTR+jTR+o0.2c", + box="+gwhite+p1p", + **kwargs, +): r""" Plot legends on maps. @@ -42,10 +51,12 @@ def legend(self, spec=None, position="JTR+jTR+o0.2c", box="+gwhite+p1p", **kwarg Parameters ---------- - spec : None or str - Either ``None`` [Default] for using the automatically generated legend - specification file, or a *filename* pointing to the legend - specification file. + spec + The legend specification file. + + - ``None`` for using the automatically generated legend specification file. + - A *filename* pointing to the legend specification file. + - A io.StringIO object containing the legend specification. {projection} {region} position : str @@ -75,12 +86,16 @@ def legend(self, spec=None, position="JTR+jTR+o0.2c", box="+gwhite+p1p", **kwarg if kwargs.get("F") is None: kwargs["F"] = box - with Session() as lib: - if spec is None: - specfile = "" - elif data_kind(spec) == "file" and not is_nonstr_iter(spec): - # Is a file but not a list of files + if spec is None: + specfile = "" + else: + kind = data_kind(spec) + if (kind == "file" and not is_nonstr_iter(spec)) or kind == "stringio": + # Is a file but not a list of files or a SrtringIO object specfile = spec else: raise GMTInvalidInput(f"Unrecognized data type: {type(spec)}") - lib.call_module(module="legend", args=build_arg_list(kwargs, infile=specfile)) + + with Session() as lib: + with lib.virtualfile_in(data=specfile) as vintbl: + lib.call_module(module="legend", args=build_arg_list(kwargs, infile=vintbl)) From 37c3c2fec47d9323541070237ff060bfe358a9a9 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 12 Jul 2024 01:06:19 +0800 Subject: [PATCH 05/28] Add a legend test for StringIO input --- pygmt/tests/test_legend.py | 90 ++++++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 38 deletions(-) diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index 5280c131cda..547164df7fd 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -2,6 +2,7 @@ Test Figure.legend. """ +import io from pathlib import Path import pytest @@ -10,12 +11,44 @@ from pygmt.helpers import GMTTempFile +@pytest.fixture(scope="module", name="legend_spec") +def fixture_legend_spec(): + """ + Return a legend specification file content. + """ + return """ +G -0.1i +H 24 Times-Roman My Map Legend +D 0.2i 1p +N 2 +V 0 1p +S 0.1i c 0.15i p300/12 0.25p 0.3i This circle is hachured +S 0.1i e 0.15i yellow 0.25p 0.3i This ellipse is yellow +S 0.1i w 0.15i green 0.25p 0.3i This wedge is green +S 0.1i f0.1i+l+t 0.25i blue 0.25p 0.3i This is a fault +S 0.1i - 0.15i - 0.25p,- 0.3i A dashed contour +S 0.1i v0.1i+a40+e 0.25i magenta 0.25p 0.3i This is a vector +S 0.1i i 0.15i cyan 0.25p 0.3i This triangle is boring +V 0 1p +D 0.2i 1p +N 1 +G 0.05i +G 0.05i +G 0.05i +L 9 4 R Smith et al., @%5%J. Geophys. Res., 99@%%, 2000 +G 0.1i +P +T Let us just try some simple text that can go on a few lines. +T There is no easy way to predetermine how many lines will be required, +T so we may have to adjust the box height to get the right size box. +""" + + @pytest.mark.mpl_image_compare def test_legend_position(): """ Test that plots a position with each of the four legend coordinate systems. """ - fig = Figure() fig.basemap(region=[-2, 2, -2, 2], frame=True) positions = ["jTR+jTR", "g0/1", "n0.2/0.2", "x4i/2i/2i"] @@ -30,14 +63,10 @@ def test_legend_default_position(): """ Test using the default legend position. """ - fig = Figure() - fig.basemap(region=[-1, 1, -1, 1], frame=True) - fig.plot(x=[0], y=[0], style="p10p", label="Default") fig.legend() - return fig @@ -59,48 +88,33 @@ def test_legend_entries(): fig.plot(data="@Table_5_11.txt", pen="1.5p,gray", label="My lines") fig.plot(data="@Table_5_11.txt", style="t0.15i", fill="orange", label="Oranges") fig.legend(position="JTR+jTR") - return fig @pytest.mark.mpl_image_compare -def test_legend_specfile(): +def test_legend_specfile(legend_spec): """ - Test specfile functionality. + Test Figure.legend with a legend specification file. """ - - specfile_contents = """ -G -0.1i -H 24 Times-Roman My Map Legend -D 0.2i 1p -N 2 -V 0 1p -S 0.1i c 0.15i p300/12 0.25p 0.3i This circle is hachured -S 0.1i e 0.15i yellow 0.25p 0.3i This ellipse is yellow -S 0.1i w 0.15i green 0.25p 0.3i This wedge is green -S 0.1i f0.1i+l+t 0.25i blue 0.25p 0.3i This is a fault -S 0.1i - 0.15i - 0.25p,- 0.3i A dashed contour -S 0.1i v0.1i+a40+e 0.25i magenta 0.25p 0.3i This is a vector -S 0.1i i 0.15i cyan 0.25p 0.3i This triangle is boring -V 0 1p -D 0.2i 1p -N 1 -G 0.05i -G 0.05i -G 0.05i -L 9 4 R Smith et al., @%5%J. Geophys. Res., 99@%%, 2000 -G 0.1i -P -T Let us just try some simple text that can go on a few lines. -T There is no easy way to predetermine how many lines will be required, -T so we may have to adjust the box height to get the right size box. -""" - with GMTTempFile() as specfile: - Path(specfile.name).write_text(specfile_contents, encoding="utf-8") + Path(specfile.name).write_text(legend_spec, encoding="utf-8") + spec = specfile.name + fig = Figure() fig.basemap(projection="x6i", region=[0, 1, 0, 1], frame=True) - fig.legend(specfile.name, position="JTM+jCM+w5i") + fig.legend(spec, position="JTM+jCM+w5i") + return fig + + +@pytest.mark.mpl_image_compare(filename="test_legend_specfile.png") +def test_legend_stringio(legend_spec): + """ + Test Figure.legend with a legend specification io.StringIO object. + """ + spec = io.StringIO(legend_spec) + fig = Figure() + fig.basemap(projection="x6i", region=[0, 1, 0, 1], frame=True) + fig.legend(spec, position="JTM+jCM+w5i") return fig From 8e1a60972ca51a5fc6f47209371afbd0eb09f9c1 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Wed, 17 Jul 2024 14:59:16 +0800 Subject: [PATCH 06/28] Some updates --- doc/api/index.rst | 1 + pygmt/clib/session.py | 23 +++++++++++++---------- pygmt/helpers/utils.py | 15 +++++++++------ pygmt/src/legend.py | 6 ++++-- pygmt/tests/test_legend.py | 2 +- 5 files changed, 28 insertions(+), 19 deletions(-) diff --git a/doc/api/index.rst b/doc/api/index.rst index 5cf28bb3ebb..5f5f028e613 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -316,5 +316,6 @@ Low level access (these are mostly used by the :mod:`pygmt.clib` package): clib.Session.get_libgmt_func clib.Session.virtualfile_from_data clib.Session.virtualfile_from_grid + clib.Session.virtualfile_from_stringio clib.Session.virtualfile_from_matrix clib.Session.virtualfile_from_vectors diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index c1662bb8548..30af985d577 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1537,22 +1537,21 @@ def virtualfile_from_grid(self, grid): @contextlib.contextmanager def virtualfile_from_stringio(self, stringio: io.StringIO): r""" - Store a StringIO object in a virtual file. + Store a :class:`io.StringIO` object in a virtual file. - Store the contents of a StringIO object in a GMT_DATASET container and - create a virtual file to pass to a GMT module. + Store the contents of a :class:`io.StringIO` object in a GMT_DATASET container + and create a virtual file to pass to a GMT module. Parameters ---------- stringio - The StringIO object containing the data to be stored in the virtual - file. + The :class:`io.StringIO` object containing the data to be stored in the + virtual file. Yields ------ fname - The name of the virtual file. Pass this as a file name argument to a - GMT module. + The name of the virtual file. Examples -------- @@ -1563,9 +1562,13 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): ... ) >>> with Session() as lib: ... with lib.virtualfile_from_stringio(stringio) as fin: - ... lib.call_module("legend", [fin, "-Dx0/0+w5c/5c"]) + ... lib.virtualfile_to_dataset(vfname=fin, output_type="pandas") + 0 + 0 H 24p Legend + 1 N 2 + 2 S 0.1i c 0.15i p300/12 0.25p 0.3i My circle """ - # Parse the strings in the StringIO object. + # Parse the strings in the io.StringIO object. # For simplicity, we make a few assumptions. # - "#" indicates a comment line # - ">" indicates a segment header @@ -1628,7 +1631,7 @@ def virtualfile_in( # noqa: PLR0912 check_kind : str or None 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 pathlib.Path or xarray.DataArray or {table-like} or None + data : str or pathlib.Path io.StringIO 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. diff --git a/pygmt/helpers/utils.py b/pygmt/helpers/utils.py index 1c4e41c90f6..ab31dfd6ace 100644 --- a/pygmt/helpers/utils.py +++ b/pygmt/helpers/utils.py @@ -13,7 +13,7 @@ import warnings import webbrowser from collections.abc import Iterable, Sequence -from typing import Any +from typing import Any, Literal import xarray as xr from pygmt.encodings import charset @@ -112,7 +112,11 @@ def _validate_data_input( raise GMTInvalidInput("data must provide x, y, and z columns.") -def data_kind(data=None, x=None, y=None, z=None, required_z=False, required_data=True): +def data_kind( + data=None, x=None, y=None, z=None, required_z=False, required_data=True +) -> Literal[ + "arg", "file", "geojson", "grid", "image", "matrix", "stringio", "vectors" +]: r""" Check what kind of data is provided to a module. @@ -120,6 +124,7 @@ def data_kind(data=None, x=None, y=None, z=None, required_z=False, required_data * a file name provided as 'data' * a pathlib.PurePath object provided as 'data' + * a io.StringIO object provided as 'data' * an xarray.DataArray object provided as 'data' * a 2-D matrix provided as 'data' * 1-D arrays x and y (and z, optionally) @@ -146,13 +151,11 @@ def data_kind(data=None, x=None, y=None, z=None, required_z=False, required_data Returns ------- - kind : str - One of ``'arg'``, ``'file'``, ``'grid'``, ``image``, ``'geojson'``, - ``'matrix'``, or ``'stringio'``, ``'vectors'``. + kind + The data kind. Examples -------- - >>> import numpy as np >>> import xarray as xr >>> import pathlib diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 3da276ded0d..9c947859b69 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -52,11 +52,13 @@ def legend( Parameters ---------- spec - The legend specification file. + The legend specification. It can be: - ``None`` for using the automatically generated legend specification file. - A *filename* pointing to the legend specification file. - - A io.StringIO object containing the legend specification. + - A :class:`io.StringIO` object containing the legend specification. + + See :gmt-docs:`legend.html` for the definition of the legend specification. {projection} {region} position : str diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index 547164df7fd..902b10942e2 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -109,7 +109,7 @@ def test_legend_specfile(legend_spec): @pytest.mark.mpl_image_compare(filename="test_legend_specfile.png") def test_legend_stringio(legend_spec): """ - Test Figure.legend with a legend specification io.StringIO object. + Test Figure.legend with a legend specification from an io.StringIO object. """ spec = io.StringIO(legend_spec) fig = Figure() From 89757ec4af727abb58057f3dbf591781520efa9d Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sun, 21 Jul 2024 10:47:05 +0800 Subject: [PATCH 07/28] Fix styling issue --- pygmt/helpers/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/helpers/utils.py b/pygmt/helpers/utils.py index f0951189bde..59dd5e64180 100644 --- a/pygmt/helpers/utils.py +++ b/pygmt/helpers/utils.py @@ -119,7 +119,7 @@ def _validate_data_input( def data_kind( data: Any = None, required: bool = True ) -> Literal["arg", "file", "geojson", "grid", "image", "matrix", "vectors"]: - """ + r""" Check the kind of data that is provided to a module. The ``data`` argument can be in any type, but only following types are supported: From 5f4d21f3e2e7bde588f849f3227f52931b319c04 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Wed, 24 Jul 2024 17:05:34 +0800 Subject: [PATCH 08/28] Updates --- pygmt/clib/session.py | 2 +- pygmt/helpers/utils.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index 542c9fde88e..dc2868d9bb4 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1725,7 +1725,7 @@ def virtualfile_in( # noqa: PLR0912 check_kind : str or None 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 pathlib.Path io.StringIO or xarray.DataArray or {table-like} or None + 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. diff --git a/pygmt/helpers/utils.py b/pygmt/helpers/utils.py index 2e9ed02330f..b85975e2906 100644 --- a/pygmt/helpers/utils.py +++ b/pygmt/helpers/utils.py @@ -190,7 +190,9 @@ def _check_encoding( def data_kind( data: Any = None, required: bool = True -) -> Literal["arg", "file", "geojson", "grid", "image", "matrix", "vectors"]: +) -> Literal[ + "arg", "file", "geojson", "grid", "image", "matrix", "stringio", "vectors" +]: r""" Check the kind of data that is provided to a module. @@ -203,11 +205,10 @@ def data_kind( - None, bool, int or float type representing an optional arguments - a geo-like Python object that implements ``__geo_interface__`` (e.g., geopandas.GeoDataFrame or shapely.geometry) - * a :class:`io.StringIO` object Parameters ---------- - data : str, io.StringIO, pathlib.PurePath, None, bool, xarray.DataArray or {table-like} + data : str, pathlib.PurePath, None, bool, xarray.DataArray or {table-like} Pass in either a file name or :class:`pathlib.Path` to an ASCII data table, an :class:`xarray.DataArray`, a 1-D/2-D {table-classes} or an option argument. From 36820988a2acf3367c1a2c665e769b716bfcf534 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 26 Jul 2024 23:14:00 +0800 Subject: [PATCH 09/28] Improve Figure.legend --- pygmt/src/legend.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 9c947859b69..e7d017c1b90 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -55,7 +55,8 @@ def legend( The legend specification. It can be: - ``None`` for using the automatically generated legend specification file. - - A *filename* pointing to the legend specification file. + - A string or a :class:`pathlib.PurePath` object pointing to the legend + specification file. - A :class:`io.StringIO` object containing the legend specification. See :gmt-docs:`legend.html` for the definition of the legend specification. @@ -88,14 +89,12 @@ def legend( if kwargs.get("F") is None: kwargs["F"] = box - if spec is None: - specfile = "" - else: - kind = data_kind(spec) - if (kind == "file" and not is_nonstr_iter(spec)) or kind == "stringio": - # Is a file but not a list of files or a SrtringIO object + match data_kind(spec): + case "vectors": # spec is None + specfile = "" + case kind if kind == "file" and not is_nonstr_iter(spec): specfile = spec - else: + case _: raise GMTInvalidInput(f"Unrecognized data type: {type(spec)}") with Session() as lib: From 146e430ec74c114ae4fbc603d66396457213faae Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Wed, 11 Sep 2024 10:03:38 +0800 Subject: [PATCH 10/28] Fix legend --- pygmt/src/legend.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index e7d017c1b90..3867c775ea0 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -94,6 +94,8 @@ def legend( specfile = "" case kind if kind == "file" and not is_nonstr_iter(spec): specfile = spec + case "stringio": + specfile = spec case _: raise GMTInvalidInput(f"Unrecognized data type: {type(spec)}") From c589a40dd03d97a46330a2cf95dc35f01ef76498 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Wed, 11 Sep 2024 10:19:33 +0800 Subject: [PATCH 11/28] Updates --- pygmt/clib/session.py | 5 ++++- pygmt/helpers/utils.py | 4 +++- pygmt/src/legend.py | 16 ++++++---------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index ed3fbf28527..df733340a0a 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1634,7 +1634,10 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): >>> import io >>> from pygmt.clib import Session >>> stringio = io.StringIO( - ... "H 24p Legend\nN 2\nS 0.1i c 0.15i p300/12 0.25p 0.3i My circle" + ... "# Comment\n" + ... "H 24p Legend\n" + ... "N 2\n" + ... "S 0.1i c 0.15i p300/12 0.25p 0.3i My circle\n" ... ) >>> with Session() as lib: ... with lib.virtualfile_from_stringio(stringio) as fin: diff --git a/pygmt/helpers/utils.py b/pygmt/helpers/utils.py index 47137af28e6..6585bb7566b 100644 --- a/pygmt/helpers/utils.py +++ b/pygmt/helpers/utils.py @@ -247,7 +247,9 @@ def data_kind( >>> data_kind(data=io.StringIO("TEXT1\nTEXT23\n")) 'stringio' """ - kind: Literal["arg", "file", "geojson", "grid", "image", "matrix", "vectors"] + kind: Literal[ + "arg", "file", "geojson", "grid", "image", "matrix", "stringio", "vectors" + ] if isinstance(data, str | pathlib.PurePath) or ( isinstance(data, list | tuple) and all(isinstance(_file, str | pathlib.PurePath) for _file in data) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 3867c775ea0..1f18650fa74 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -89,16 +89,12 @@ def legend( if kwargs.get("F") is None: kwargs["F"] = box - match data_kind(spec): - case "vectors": # spec is None - specfile = "" - case kind if kind == "file" and not is_nonstr_iter(spec): - specfile = spec - case "stringio": - specfile = spec - case _: - raise GMTInvalidInput(f"Unrecognized data type: {type(spec)}") + kind = data_kind(spec) + if kind not in {"vectors", "file", "stringio"}: + raise GMTInvalidInput(f"Unrecognized data type: {type(spec)}") + if kind == "file" and is_nonstr_iter(spec): + raise GMTInvalidInput("Only one legend specification file is allowed.") with Session() as lib: - with lib.virtualfile_in(data=specfile) as vintbl: + with lib.virtualfile_in(data=spec, required_data=False) as vintbl: lib.call_module(module="legend", args=build_arg_list(kwargs, infile=vintbl)) From ff90b2ee0c96e834a6bd40ffe508942b1001cdf2 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Wed, 11 Sep 2024 10:34:50 +0800 Subject: [PATCH 12/28] Figure.legend: Refactor to simplify the logic of checking legend specification --- pygmt/src/legend.py | 37 +++++++++++------- pygmt/tests/test_legend.py | 80 +++++++++++++++++++------------------- 2 files changed, 65 insertions(+), 52 deletions(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index e5a7ebefab0..ae3b9616694 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -2,6 +2,8 @@ legend - Plot a legend. """ +import pathlib + from pygmt.clib import Session from pygmt.exceptions import GMTInvalidInput from pygmt.helpers import ( @@ -26,7 +28,13 @@ t="transparency", ) @kwargs_to_strings(R="sequence", c="sequence_comma", p="sequence") -def legend(self, spec=None, position="JTR+jTR+o0.2c", box="+gwhite+p1p", **kwargs): +def legend( + self, + spec: str | pathlib.PurePath | None = None, + position="JTR+jTR+o0.2c", + box="+gwhite+p1p", + **kwargs, +): r""" Plot legends on maps. @@ -42,10 +50,14 @@ def legend(self, spec=None, position="JTR+jTR+o0.2c", box="+gwhite+p1p", **kwarg Parameters ---------- - spec : None or str - Either ``None`` [Default] for using the automatically generated legend - specification file, or a *filename* pointing to the legend - specification file. + spec + The legend specification. It can be: + + - ``None`` means using the automatically generated legend specification file. + - A string or a :class:`pathlib.PurePath` object pointing to the legend + specification file. + + See :gmt-docs:`legend.html` for the definition of the legend specification. {projection} {region} position : str @@ -75,12 +87,11 @@ def legend(self, spec=None, position="JTR+jTR+o0.2c", box="+gwhite+p1p", **kwarg if kwargs.get("F") is None: kwargs["F"] = box + kind = data_kind(spec) + if kind not in {"vectors", "file"}: # kind="vectors" means spec is None + raise GMTInvalidInput(f"Unrecognized data type: {type(spec)}") + if kind == "file" and is_nonstr_iter(spec): + raise GMTInvalidInput("Only one legend specification file is allowed.") + with Session() as lib: - if spec is None: - specfile = "" - elif data_kind(spec) == "file" and not is_nonstr_iter(spec): - # Is a file but not a list of files - specfile = spec - else: - raise GMTInvalidInput(f"Unrecognized data type: {type(spec)}") - lib.call_module(module="legend", args=build_arg_list(kwargs, infile=specfile)) + lib.call_module(module="legend", args=build_arg_list(kwargs, infile=spec)) diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index 5280c131cda..e2edfa8259b 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -10,12 +10,44 @@ from pygmt.helpers import GMTTempFile +@pytest.fixture(scope="module", name="legend_spec") +def fixture_legend_spec(): + """ + A string contains a legend specification. + """ + return """ +G -0.1i +H 24 Times-Roman My Map Legend +D 0.2i 1p +N 2 +V 0 1p +S 0.1i c 0.15i p300/12 0.25p 0.3i This circle is hachured +S 0.1i e 0.15i yellow 0.25p 0.3i This ellipse is yellow +S 0.1i w 0.15i green 0.25p 0.3i This wedge is green +S 0.1i f0.1i+l+t 0.25i blue 0.25p 0.3i This is a fault +S 0.1i - 0.15i - 0.25p,- 0.3i A dashed contour +S 0.1i v0.1i+a40+e 0.25i magenta 0.25p 0.3i This is a vector +S 0.1i i 0.15i cyan 0.25p 0.3i This triangle is boring +V 0 1p +D 0.2i 1p +N 1 +G 0.05i +G 0.05i +G 0.05i +L 9 4 R Smith et al., @%5%J. Geophys. Res., 99@%%, 2000 +G 0.1i +P +T Let us just try some simple text that can go on a few lines. +T There is no easy way to predetermine how many lines will be required, +T so we may have to adjust the box height to get the right size box. +""" + + @pytest.mark.mpl_image_compare def test_legend_position(): """ - Test that plots a position with each of the four legend coordinate systems. + Test positioning the legend with different coordinate systems. """ - fig = Figure() fig.basemap(region=[-2, 2, -2, 2], frame=True) positions = ["jTR+jTR", "g0/1", "n0.2/0.2", "x4i/2i/2i"] @@ -30,14 +62,10 @@ def test_legend_default_position(): """ Test using the default legend position. """ - fig = Figure() - fig.basemap(region=[-1, 1, -1, 1], frame=True) - fig.plot(x=[0], y=[0], style="p10p", label="Default") fig.legend() - return fig @@ -45,7 +73,7 @@ def test_legend_default_position(): @pytest.mark.mpl_image_compare def test_legend_entries(): """ - Test different marker types/shapes. + Test legend using the automatically generated legend entries. """ fig = Figure() fig.basemap(projection="x1i", region=[0, 7, 3, 7], frame=True) @@ -59,45 +87,16 @@ def test_legend_entries(): fig.plot(data="@Table_5_11.txt", pen="1.5p,gray", label="My lines") fig.plot(data="@Table_5_11.txt", style="t0.15i", fill="orange", label="Oranges") fig.legend(position="JTR+jTR") - return fig @pytest.mark.mpl_image_compare -def test_legend_specfile(): +def test_legend_specfile(legend_spec): """ - Test specfile functionality. + Test passing a legend specification file. """ - - specfile_contents = """ -G -0.1i -H 24 Times-Roman My Map Legend -D 0.2i 1p -N 2 -V 0 1p -S 0.1i c 0.15i p300/12 0.25p 0.3i This circle is hachured -S 0.1i e 0.15i yellow 0.25p 0.3i This ellipse is yellow -S 0.1i w 0.15i green 0.25p 0.3i This wedge is green -S 0.1i f0.1i+l+t 0.25i blue 0.25p 0.3i This is a fault -S 0.1i - 0.15i - 0.25p,- 0.3i A dashed contour -S 0.1i v0.1i+a40+e 0.25i magenta 0.25p 0.3i This is a vector -S 0.1i i 0.15i cyan 0.25p 0.3i This triangle is boring -V 0 1p -D 0.2i 1p -N 1 -G 0.05i -G 0.05i -G 0.05i -L 9 4 R Smith et al., @%5%J. Geophys. Res., 99@%%, 2000 -G 0.1i -P -T Let us just try some simple text that can go on a few lines. -T There is no easy way to predetermine how many lines will be required, -T so we may have to adjust the box height to get the right size box. -""" - with GMTTempFile() as specfile: - Path(specfile.name).write_text(specfile_contents, encoding="utf-8") + Path(specfile.name).write_text(legend_spec, encoding="utf-8") fig = Figure() fig.basemap(projection="x6i", region=[0, 1, 0, 1], frame=True) fig.legend(specfile.name, position="JTM+jCM+w5i") @@ -111,3 +110,6 @@ def test_legend_fails(): fig = Figure() with pytest.raises(GMTInvalidInput): fig.legend(spec=["@Table_5_11.txt"]) + + with pytest.raises(GMTInvalidInput): + fig.legend(spec=[1, 2]) From 4e4bd2da0c1c2974ffcdf76d57dbe21b5e9571a5 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 12 Sep 2024 08:46:42 +0800 Subject: [PATCH 13/28] FIx --- pygmt/tests/test_legend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index db1295e00a3..d79ee2723cb 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -94,7 +94,7 @@ def test_legend_entries(): @pytest.mark.mpl_image_compare def test_legend_specfile(legend_spec): """ - Test Figure.legend with a legend specification file. + Test passing a legend specification file. """ with GMTTempFile() as specfile: Path(specfile.name).write_text(legend_spec, encoding="utf-8") @@ -107,7 +107,7 @@ def test_legend_specfile(legend_spec): @pytest.mark.mpl_image_compare(filename="test_legend_specfile.png") def test_legend_stringio(legend_spec): """ - Test Figure.legend with a legend specification from an io.StringIO object. + Test passing an legend specification via an io.StringIO object. """ spec = io.StringIO(legend_spec) fig = Figure() From deb917d247d4b073f6002d9b33b69b17d789c498 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 12 Sep 2024 08:56:29 +0800 Subject: [PATCH 14/28] Revert changes in legend --- pygmt/src/legend.py | 9 +++------ pygmt/tests/test_legend.py | 13 ------------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index b218179dcdd..ae3b9616694 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -2,7 +2,6 @@ legend - Plot a legend. """ -import io import pathlib from pygmt.clib import Session @@ -31,7 +30,7 @@ @kwargs_to_strings(R="sequence", c="sequence_comma", p="sequence") def legend( self, - spec: str | pathlib.PurePath | io.StringIO | None = None, + spec: str | pathlib.PurePath | None = None, position="JTR+jTR+o0.2c", box="+gwhite+p1p", **kwargs, @@ -57,7 +56,6 @@ def legend( - ``None`` means using the automatically generated legend specification file. - A string or a :class:`pathlib.PurePath` object pointing to the legend specification file. - - A :class:`io.StringIO` object containing the legend specification. See :gmt-docs:`legend.html` for the definition of the legend specification. {projection} @@ -90,11 +88,10 @@ def legend( kwargs["F"] = box kind = data_kind(spec) - if kind not in {"vectors", "file", "stringio"}: # kind="vectors" means spec is None + if kind not in {"vectors", "file"}: # kind="vectors" means spec is None raise GMTInvalidInput(f"Unrecognized data type: {type(spec)}") if kind == "file" and is_nonstr_iter(spec): raise GMTInvalidInput("Only one legend specification file is allowed.") with Session() as lib: - with lib.virtualfile_in(data=spec, required_data=False) as vintbl: - lib.call_module(module="legend", args=build_arg_list(kwargs, infile=vintbl)) + lib.call_module(module="legend", args=build_arg_list(kwargs, infile=spec)) diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index d79ee2723cb..e2edfa8259b 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -2,7 +2,6 @@ Test Figure.legend. """ -import io from pathlib import Path import pytest @@ -101,18 +100,6 @@ def test_legend_specfile(legend_spec): fig = Figure() fig.basemap(projection="x6i", region=[0, 1, 0, 1], frame=True) fig.legend(specfile.name, position="JTM+jCM+w5i") - return fig - - -@pytest.mark.mpl_image_compare(filename="test_legend_specfile.png") -def test_legend_stringio(legend_spec): - """ - Test passing an legend specification via an io.StringIO object. - """ - spec = io.StringIO(legend_spec) - fig = Figure() - fig.basemap(projection="x6i", region=[0, 1, 0, 1], frame=True) - fig.legend(spec, position="JTM+jCM+w5i") return fig From dece315340f5d3b6fe89f689d05e219665698762 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 13 Sep 2024 08:10:28 +0800 Subject: [PATCH 15/28] Improve docstrings --- pygmt/clib/session.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index df733340a0a..e9d56d4bb1a 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1618,6 +1618,12 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): Store the contents of a :class:`io.StringIO` object in a GMT_DATASET container and create a virtual file to pass to a GMT module. + For simplicity, currently we make following assumptions in the stringio object + + - ``"#"`` indicates a comment line. + - ``">"`` indicates a segment header. + - The object only contains one table and one segment. + Parameters ---------- stringio @@ -1633,6 +1639,7 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): -------- >>> import io >>> from pygmt.clib import Session + >>> # A StringIO object containing legend specifications >>> stringio = io.StringIO( ... "# Comment\n" ... "H 24p Legend\n" @@ -1648,10 +1655,6 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): 2 S 0.1i c 0.15i p300/12 0.25p 0.3i My circle """ # Parse the strings in the io.StringIO object. - # For simplicity, we make a few assumptions. - # - "#" indicates a comment line - # - ">" indicates a segment header - # - Only one table and one segment header = None string_arrays = [] for line in stringio.getvalue().splitlines(): From 486fce7f40ef99790b45a18256406ae4f62fe374 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 13 Sep 2024 14:23:11 +0800 Subject: [PATCH 16/28] Support mutli-segment stringio input --- pygmt/clib/session.py | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index e9d56d4bb1a..7baeea6ba31 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1622,7 +1622,7 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): - ``"#"`` indicates a comment line. - ``">"`` indicates a segment header. - - The object only contains one table and one segment. + - The object only contains one table. Parameters ---------- @@ -1655,19 +1655,26 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): 2 S 0.1i c 0.15i p300/12 0.25p 0.3i My circle """ # Parse the strings in the io.StringIO object. - header = None - string_arrays = [] + segments = [] + current_segment = {"header": "", "data": []} for line in stringio.getvalue().splitlines(): if line.startswith("#"): # Skip comments continue if line.startswith(">"): # Segment header - if header is not None: # Only one segment is allowed now. - raise GMTInvalidInput("Only one segment is allowed.") - header = line - continue - string_arrays.append(line) - # Only one table and one segment. No numeric data, so n_columns is 0. - n_tables, n_segments, n_rows, n_columns = 1, 1, len(string_arrays), 0 + if current_segment["data"]: # If we have data, start a new segment + segments.append(current_segment) + current_segment = {"header": "", "data": []} + current_segment["header"] = line.strip(">").strip() + else: + current_segment["data"].append(line) + if current_segment["data"]: # Add the last segment if it has data + segments.append(current_segment) + + # One table with one or more segments. No numeric data, so n_columns is 0. + n_tables = 1 + n_segments = len(segments) + n_rows = sum(len(segment["data"]) for segment in segments) + n_columns = 0 family, geometry = "GMT_IS_DATASET", "GMT_IS_TEXT" dataset = self.create_data( @@ -1677,18 +1684,20 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): dim=[n_tables, n_segments, n_rows, n_columns], ) dataset = ctp.cast(dataset, ctp.POINTER(_GMT_DATASET)) - # Assign the strings to the segment - seg = dataset.contents.table[0].contents.segment[0].contents - if header is not None: - seg.header = header.encode() - seg.text = strings_to_ctypes_array(string_arrays) + table = dataset.contents.table[0].contents + for i, segment in enumerate(segments): + seg = table.segment[i].contents + if segment["header"] != "": + seg.header = segment["header"].encode() + seg.text = strings_to_ctypes_array(segment["data"]) with self.open_virtualfile(family, geometry, "GMT_IN", dataset) as vfile: try: yield vfile finally: # Must set the text to None to avoid double freeing the memory - seg.text = None + for i in range(n_segments): + table.segment[i].contents.text = None def virtualfile_in( # noqa: PLR0912 self, From 021a97a0a7e8b6faeb8646eb2f5fd13388f2c612 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 13 Sep 2024 14:46:23 +0800 Subject: [PATCH 17/28] Revert "Support mutli-segment stringio input" This reverts commit 486fce7f40ef99790b45a18256406ae4f62fe374. --- pygmt/clib/session.py | 41 ++++++++++++++++------------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index 7baeea6ba31..e9d56d4bb1a 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1622,7 +1622,7 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): - ``"#"`` indicates a comment line. - ``">"`` indicates a segment header. - - The object only contains one table. + - The object only contains one table and one segment. Parameters ---------- @@ -1655,26 +1655,19 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): 2 S 0.1i c 0.15i p300/12 0.25p 0.3i My circle """ # Parse the strings in the io.StringIO object. - segments = [] - current_segment = {"header": "", "data": []} + header = None + string_arrays = [] for line in stringio.getvalue().splitlines(): if line.startswith("#"): # Skip comments continue if line.startswith(">"): # Segment header - if current_segment["data"]: # If we have data, start a new segment - segments.append(current_segment) - current_segment = {"header": "", "data": []} - current_segment["header"] = line.strip(">").strip() - else: - current_segment["data"].append(line) - if current_segment["data"]: # Add the last segment if it has data - segments.append(current_segment) - - # One table with one or more segments. No numeric data, so n_columns is 0. - n_tables = 1 - n_segments = len(segments) - n_rows = sum(len(segment["data"]) for segment in segments) - n_columns = 0 + if header is not None: # Only one segment is allowed now. + raise GMTInvalidInput("Only one segment is allowed.") + header = line + continue + string_arrays.append(line) + # Only one table and one segment. No numeric data, so n_columns is 0. + n_tables, n_segments, n_rows, n_columns = 1, 1, len(string_arrays), 0 family, geometry = "GMT_IS_DATASET", "GMT_IS_TEXT" dataset = self.create_data( @@ -1684,20 +1677,18 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): dim=[n_tables, n_segments, n_rows, n_columns], ) dataset = ctp.cast(dataset, ctp.POINTER(_GMT_DATASET)) - table = dataset.contents.table[0].contents - for i, segment in enumerate(segments): - seg = table.segment[i].contents - if segment["header"] != "": - seg.header = segment["header"].encode() - seg.text = strings_to_ctypes_array(segment["data"]) + # Assign the strings to the segment + seg = dataset.contents.table[0].contents.segment[0].contents + if header is not None: + seg.header = header.encode() + seg.text = strings_to_ctypes_array(string_arrays) with self.open_virtualfile(family, geometry, "GMT_IN", dataset) as vfile: try: yield vfile finally: # Must set the text to None to avoid double freeing the memory - for i in range(n_segments): - table.segment[i].contents.text = None + seg.text = None def virtualfile_in( # noqa: PLR0912 self, From f6da4058b57e3b7b96ba5d37590b355719f73085 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 13 Sep 2024 14:46:53 +0800 Subject: [PATCH 18/28] Remove the leading '>' from header --- pygmt/clib/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index e9d56d4bb1a..ff1bb51c0b8 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1663,7 +1663,7 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): if line.startswith(">"): # Segment header if header is not None: # Only one segment is allowed now. raise GMTInvalidInput("Only one segment is allowed.") - header = line + header = line.strip(">").lstrip() continue string_arrays.append(line) # Only one table and one segment. No numeric data, so n_columns is 0. From 824d8610276b0a1dd1dfcf860a3df1ccc92a3a2f Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 13 Sep 2024 14:51:02 +0800 Subject: [PATCH 19/28] Also need to set the header pointer to None --- pygmt/clib/session.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index ff1bb51c0b8..13df401a103 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1687,7 +1687,9 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): try: yield vfile finally: - # Must set the text to None to avoid double freeing the memory + # Must set the pointers to None to avoid double freeing the memory. + # Maybe upstream bug. + seg.header = None seg.text = None def virtualfile_in( # noqa: PLR0912 From 640e9a93f986abfa5a74d68fd1ba18f6a6c60dec Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 13 Sep 2024 14:23:11 +0800 Subject: [PATCH 20/28] Support mutli-segment stringio input --- pygmt/clib/session.py | 44 ++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index 13df401a103..35f405c8000 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1622,7 +1622,7 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): - ``"#"`` indicates a comment line. - ``">"`` indicates a segment header. - - The object only contains one table and one segment. + - The object only contains one table. Parameters ---------- @@ -1655,19 +1655,26 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): 2 S 0.1i c 0.15i p300/12 0.25p 0.3i My circle """ # Parse the strings in the io.StringIO object. - header = None - string_arrays = [] + segments = [] + current_segment = {"header": "", "data": []} for line in stringio.getvalue().splitlines(): if line.startswith("#"): # Skip comments continue if line.startswith(">"): # Segment header - if header is not None: # Only one segment is allowed now. - raise GMTInvalidInput("Only one segment is allowed.") - header = line.strip(">").lstrip() - continue - string_arrays.append(line) - # Only one table and one segment. No numeric data, so n_columns is 0. - n_tables, n_segments, n_rows, n_columns = 1, 1, len(string_arrays), 0 + if current_segment["data"]: # If we have data, start a new segment + segments.append(current_segment) + current_segment = {"header": "", "data": []} + current_segment["header"] = line.strip(">").strip() + else: + current_segment["data"].append(line) + if current_segment["data"]: # Add the last segment if it has data + segments.append(current_segment) + + # One table with one or more segments. No numeric data, so n_columns is 0. + n_tables = 1 + n_segments = len(segments) + n_rows = sum(len(segment["data"]) for segment in segments) + n_columns = 0 family, geometry = "GMT_IS_DATASET", "GMT_IS_TEXT" dataset = self.create_data( @@ -1677,11 +1684,12 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): dim=[n_tables, n_segments, n_rows, n_columns], ) dataset = ctp.cast(dataset, ctp.POINTER(_GMT_DATASET)) - # Assign the strings to the segment - seg = dataset.contents.table[0].contents.segment[0].contents - if header is not None: - seg.header = header.encode() - seg.text = strings_to_ctypes_array(string_arrays) + table = dataset.contents.table[0].contents + for i, segment in enumerate(segments): + seg = table.segment[i].contents + if segment["header"] != "": + seg.header = segment["header"].encode() + seg.text = strings_to_ctypes_array(segment["data"]) with self.open_virtualfile(family, geometry, "GMT_IN", dataset) as vfile: try: @@ -1689,8 +1697,10 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): finally: # Must set the pointers to None to avoid double freeing the memory. # Maybe upstream bug. - seg.header = None - seg.text = None + for i in range(n_segments): + seg = table.segment[i].contents + seg.header = None + seg.text = None def virtualfile_in( # noqa: PLR0912 self, From 316196349091d13e956ae669272dea442a2c4a53 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 13 Sep 2024 16:49:38 +0800 Subject: [PATCH 21/28] Update docstrings --- pygmt/clib/session.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index 35f405c8000..b246db8ba61 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1618,11 +1618,10 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): Store the contents of a :class:`io.StringIO` object in a GMT_DATASET container and create a virtual file to pass to a GMT module. - For simplicity, currently we make following assumptions in the stringio object + For simplicity, currently we make following assumptions in the StringIO object - ``"#"`` indicates a comment line. - ``">"`` indicates a segment header. - - The object only contains one table. Parameters ---------- @@ -1654,7 +1653,7 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): 1 N 2 2 S 0.1i c 0.15i p300/12 0.25p 0.3i My circle """ - # Parse the strings in the io.StringIO object. + # Parse the io.StringIO object. segments = [] current_segment = {"header": "", "data": []} for line in stringio.getvalue().splitlines(): @@ -1664,7 +1663,7 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): if current_segment["data"]: # If we have data, start a new segment segments.append(current_segment) current_segment = {"header": "", "data": []} - current_segment["header"] = line.strip(">").strip() + current_segment["header"] = line.strip(">").lstrip() else: current_segment["data"].append(line) if current_segment["data"]: # Add the last segment if it has data @@ -1676,6 +1675,7 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): n_rows = sum(len(segment["data"]) for segment in segments) n_columns = 0 + # Create the GMT_DATASET container family, geometry = "GMT_IS_DATASET", "GMT_IS_TEXT" dataset = self.create_data( family, From 026f6e4e1226959d3b1a644df4439bcb37b7ace9 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 13 Sep 2024 17:24:22 +0800 Subject: [PATCH 22/28] Fix a bug in n_rows --- pygmt/clib/session.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index b246db8ba61..ebf5f816898 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1669,10 +1669,12 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): if current_segment["data"]: # Add the last segment if it has data segments.append(current_segment) - # One table with one or more segments. No numeric data, so n_columns is 0. + # One table with one or more segments. + # n_rows is the maximum number of rows/records for all segments. + # n_columns is the number of numeric data columns, so it's 0 here. n_tables = 1 n_segments = len(segments) - n_rows = sum(len(segment["data"]) for segment in segments) + n_rows = max(len(segment["data"]) for segment in segments) n_columns = 0 # Create the GMT_DATASET container From 850337e122b7a319791584e03c4559034ae66be1 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 13 Sep 2024 17:24:29 +0800 Subject: [PATCH 23/28] Add some tests --- pygmt/tests/test_clib_virtualfiles.py | 67 +++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/pygmt/tests/test_clib_virtualfiles.py b/pygmt/tests/test_clib_virtualfiles.py index b8b5ee0500d..29c8969bbe6 100644 --- a/pygmt/tests/test_clib_virtualfiles.py +++ b/pygmt/tests/test_clib_virtualfiles.py @@ -2,6 +2,7 @@ Test the C API functions related to virtual files. """ +import io from importlib.util import find_spec from itertools import product from pathlib import Path @@ -407,3 +408,69 @@ def test_inquire_virtualfile(): ]: with lib.open_virtualfile(family, geometry, "GMT_OUT", None) as vfile: assert lib.inquire_virtualfile(vfile) == lib[family] + + +class TestVirtualfileFromStringIO: + """ + Test the virtualfile_from_stringio method. + """ + + def _check_virtualfile_from_stringio(self, data: str): + """ + A helper function to check the output of the virtualfile_from_stringio method. + """ + # The expected output is the data with all comment lines removed. + expected = ( + "\n".join(line for line in data.splitlines() if not line.startswith("#")) + + "\n" + ) + stringio = io.StringIO(data) + with GMTTempFile() as outfile: + with clib.Session() as lib: + with lib.virtualfile_from_stringio(stringio) as vintbl: + lib.call_module("write", args=[vintbl, f"->{outfile.name}", "-Td"]) + output = outfile.read() + assert output == expected + + def test_virtualfile_from_stringio(self): + """ + Test the virtualfile_from_stringio method. + """ + data = ( + "# Comment\n" + "H 24p Legend\n" + "N 2\n" + "S 0.1i c 0.15i p300/12 0.25p 0.3i My circle\n" + ) + self._check_virtualfile_from_stringio(data) + + def test_one_segment(self): + """ + Test the virtualfile_from_stringio method with one segment. + """ + data = ( + "# Comment\n" + "> Segment 1\n" + "H 24p Legend\n" + "N 2\n" + "S 0.1i c 0.15i p300/12 0.25p 0.3i My circle\n" + ) + self._check_virtualfile_from_stringio(data) + + def test_multiple_segments(self): + """ + Test the virtualfile_from_stringio method with multiple segments. + """ + data = ( + "# Comment line 1\n" + "# Comment line 2\n" + "> Segment 1\n" + "H 24p Legend\n" + "N 2\n" + "S 0.1i c 0.15i p300/12 0.25p 0.3i My circle\n" + "> Segment 2\n" + "H 24p Legend\n" + "N 2\n" + "S 0.1i c 0.15i p300/12 0.25p 0.3i My circle\n" + ) + self._check_virtualfile_from_stringio(data) From ed2011826abcf73cbd4d79bab952154114361c36 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 13 Sep 2024 17:32:15 +0800 Subject: [PATCH 24/28] Fix static type checking --- pygmt/clib/session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index ebf5f816898..4fae8130d0c 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1665,7 +1665,7 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): current_segment = {"header": "", "data": []} current_segment["header"] = line.strip(">").lstrip() else: - current_segment["data"].append(line) + current_segment["data"].append(line) # type: ignore[attr-defined] if current_segment["data"]: # Add the last segment if it has data segments.append(current_segment) @@ -1690,7 +1690,7 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): for i, segment in enumerate(segments): seg = table.segment[i].contents if segment["header"] != "": - seg.header = segment["header"].encode() + seg.header = segment["header"].encode() # type: ignore[attr-defined] seg.text = strings_to_ctypes_array(segment["data"]) with self.open_virtualfile(family, geometry, "GMT_IN", dataset) as vfile: From f1b5f08815b996ea29b3570af9d8bae2dd12ec6a Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Fri, 13 Sep 2024 18:44:48 +0800 Subject: [PATCH 25/28] Improve the tests --- pygmt/clib/session.py | 2 +- pygmt/tests/test_clib_virtualfiles.py | 95 +++++++++++++++++++-------- 2 files changed, 67 insertions(+), 30 deletions(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index 4fae8130d0c..432ae37810d 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1690,7 +1690,7 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): for i, segment in enumerate(segments): seg = table.segment[i].contents if segment["header"] != "": - seg.header = segment["header"].encode() # type: ignore[attr-defined] + seg.header = segment["header"].encode() # type: ignore[attr-defined] seg.text = strings_to_ctypes_array(segment["data"]) with self.open_virtualfile(family, geometry, "GMT_IN", dataset) as vfile: diff --git a/pygmt/tests/test_clib_virtualfiles.py b/pygmt/tests/test_clib_virtualfiles.py index 29c8969bbe6..2a966de7c05 100644 --- a/pygmt/tests/test_clib_virtualfiles.py +++ b/pygmt/tests/test_clib_virtualfiles.py @@ -415,62 +415,99 @@ class TestVirtualfileFromStringIO: Test the virtualfile_from_stringio method. """ - def _check_virtualfile_from_stringio(self, data: str): + def _stringio_to_dataset(self, data: io.StringIO): """ - A helper function to check the output of the virtualfile_from_stringio method. + A helper function for check the virtualfile_from_stringio method. + + The function does the following: + + 1. Creates a virtual file from the input StringIO object. + 2. Pass the virtual file to the ``read`` module, which reads the virtual file + and writes it to another virtual file. + 3. Reads the output virtual file as a GMT_DATASET object. + 4. Extracts the header and the trailing text from the dataset and returns it as + a string. """ - # The expected output is the data with all comment lines removed. - expected = ( - "\n".join(line for line in data.splitlines() if not line.startswith("#")) - + "\n" - ) - stringio = io.StringIO(data) - with GMTTempFile() as outfile: - with clib.Session() as lib: - with lib.virtualfile_from_stringio(stringio) as vintbl: - lib.call_module("write", args=[vintbl, f"->{outfile.name}", "-Td"]) - output = outfile.read() - assert output == expected + with clib.Session() as lib: + with ( + lib.virtualfile_from_stringio(data) as vintbl, + lib.virtualfile_out(kind="dataset") as vouttbl, + ): + lib.call_module("read", args=[vintbl, vouttbl, "-Td"]) + ds = lib.read_virtualfile(vouttbl, kind="dataset").contents + + output = [] + table = ds.table[0].contents + for segment in table.segment[: table.n_segments]: + seg = segment.contents + output.append(f"> {seg.header.decode()}" if seg.header else ">") + output.extend(np.char.decode(seg.text[: seg.n_rows])) + return "\n".join(output) + "\n" def test_virtualfile_from_stringio(self): """ Test the virtualfile_from_stringio method. """ - data = ( + data = io.StringIO( "# Comment\n" "H 24p Legend\n" "N 2\n" "S 0.1i c 0.15i p300/12 0.25p 0.3i My circle\n" ) - self._check_virtualfile_from_stringio(data) + expected = ( + ">\n" + "H 24p Legend\n" + "N 2\n" + "S 0.1i c 0.15i p300/12 0.25p 0.3i My circle\n" + ) + assert self._stringio_to_dataset(data) == expected def test_one_segment(self): """ Test the virtualfile_from_stringio method with one segment. """ - data = ( + data = io.StringIO( "# Comment\n" "> Segment 1\n" - "H 24p Legend\n" - "N 2\n" - "S 0.1i c 0.15i p300/12 0.25p 0.3i My circle\n" + "1 2 3 ABC\n" + "4 5 DE\n" + "6 7 8 9 FGHIJK LMN OPQ\n" + "RSTUVWXYZ\n" ) - self._check_virtualfile_from_stringio(data) + expected = ( + "> Segment 1\n" + "1 2 3 ABC\n" + "4 5 DE\n" + "6 7 8 9 FGHIJK LMN OPQ\n" + "RSTUVWXYZ\n" + ) + assert self._stringio_to_dataset(data) == expected def test_multiple_segments(self): """ Test the virtualfile_from_stringio method with multiple segments. """ - data = ( + data = io.StringIO( "# Comment line 1\n" "# Comment line 2\n" "> Segment 1\n" - "H 24p Legend\n" - "N 2\n" - "S 0.1i c 0.15i p300/12 0.25p 0.3i My circle\n" + "1 2 3 ABC\n" + "4 5 DE\n" + "6 7 8 9 FG\n" + "# Comment line 3\n" "> Segment 2\n" - "H 24p Legend\n" - "N 2\n" - "S 0.1i c 0.15i p300/12 0.25p 0.3i My circle\n" + "1 2 3 ABC\n" + "4 5 DE\n" + "6 7 8 9 FG\n" + ) + expected = ( + "> Segment 1\n" + "1 2 3 ABC\n" + "4 5 DE\n" + "6 7 8 9 FG\n" + "> Segment 2\n" + "1 2 3 ABC\n" + "4 5 DE\n" + "6 7 8 9 FG\n" ) - self._check_virtualfile_from_stringio(data) + assert self._stringio_to_dataset(data) == expected From dfab3c767b3536e9c18db705ac836c6d9f396746 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Thu, 12 Sep 2024 08:57:51 +0800 Subject: [PATCH 26/28] Figure.legend: Support passing a StringIO object as the legend specification --- pygmt/src/legend.py | 9 ++++++--- pygmt/tests/test_legend.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/pygmt/src/legend.py b/pygmt/src/legend.py index 44c7da06570..ed34bc0d797 100644 --- a/pygmt/src/legend.py +++ b/pygmt/src/legend.py @@ -2,6 +2,7 @@ legend - Plot a legend. """ +import io import pathlib from pygmt.clib import Session @@ -30,7 +31,7 @@ @kwargs_to_strings(R="sequence", c="sequence_comma", p="sequence") def legend( self, - spec: str | pathlib.PurePath | None = None, + spec: str | pathlib.PurePath | io.StringIO | None = None, position="JTR+jTR+o0.2c", box="+gwhite+p1p", **kwargs, @@ -57,6 +58,7 @@ def legend( file - A string or a :class:`pathlib.PurePath` object pointing to the legend specification file + - A :class:`io.StringIO` object containing the legend specification. See :gmt-docs:`legend.html` for the definition of the legend specification. {projection} @@ -89,10 +91,11 @@ def legend( kwargs["F"] = box kind = data_kind(spec) - if kind not in {"vectors", "file"}: # kind="vectors" means spec is None + if kind not in {"vectors", "file", "stringio"}: # kind="vectors" means spec is None raise GMTInvalidInput(f"Unrecognized data type: {type(spec)}") if kind == "file" and is_nonstr_iter(spec): raise GMTInvalidInput("Only one legend specification file is allowed.") with Session() as lib: - lib.call_module(module="legend", args=build_arg_list(kwargs, infile=spec)) + with lib.virtualfile_in(data=spec, required_data=False) as vintbl: + lib.call_module(module="legend", args=build_arg_list(kwargs, infile=vintbl)) diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index e2edfa8259b..d79ee2723cb 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -2,6 +2,7 @@ Test Figure.legend. """ +import io from pathlib import Path import pytest @@ -100,6 +101,18 @@ def test_legend_specfile(legend_spec): fig = Figure() fig.basemap(projection="x6i", region=[0, 1, 0, 1], frame=True) fig.legend(specfile.name, position="JTM+jCM+w5i") + return fig + + +@pytest.mark.mpl_image_compare(filename="test_legend_specfile.png") +def test_legend_stringio(legend_spec): + """ + Test passing an legend specification via an io.StringIO object. + """ + spec = io.StringIO(legend_spec) + fig = Figure() + fig.basemap(projection="x6i", region=[0, 1, 0, 1], frame=True) + fig.legend(spec, position="JTM+jCM+w5i") return fig From e10cef6926913c6ba9610845d109c74fe73200f3 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Sat, 14 Sep 2024 07:30:29 +0800 Subject: [PATCH 27/28] Simplify the checking of segment header --- pygmt/clib/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index 432ae37810d..cdfc3bc4963 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -1689,7 +1689,7 @@ def virtualfile_from_stringio(self, stringio: io.StringIO): table = dataset.contents.table[0].contents for i, segment in enumerate(segments): seg = table.segment[i].contents - if segment["header"] != "": + if segment["header"]: seg.header = segment["header"].encode() # type: ignore[attr-defined] seg.text = strings_to_ctypes_array(segment["data"]) From 045d4b9f5538eb2f7e3ebf75f546203ed10161e7 Mon Sep 17 00:00:00 2001 From: Dongdong Tian Date: Mon, 16 Sep 2024 20:34:34 +0800 Subject: [PATCH 28/28] Update pygmt/tests/test_legend.py Co-authored-by: Michael Grund <23025878+michaelgrund@users.noreply.github.com> --- pygmt/tests/test_legend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/tests/test_legend.py b/pygmt/tests/test_legend.py index d79ee2723cb..3a63d74166e 100644 --- a/pygmt/tests/test_legend.py +++ b/pygmt/tests/test_legend.py @@ -107,7 +107,7 @@ def test_legend_specfile(legend_spec): @pytest.mark.mpl_image_compare(filename="test_legend_specfile.png") def test_legend_stringio(legend_spec): """ - Test passing an legend specification via an io.StringIO object. + Test passing a legend specification via an io.StringIO object. """ spec = io.StringIO(legend_spec) fig = Figure()