diff --git a/general/g.parser/standard_option.c b/general/g.parser/standard_option.c index cbcd990193c..7dba40d671c 100644 --- a/general/g.parser/standard_option.c +++ b/general/g.parser/standard_option.c @@ -88,7 +88,8 @@ static char *STD_OPT_STRINGS[] = {"G_OPT_UNDEFINED", "G_OPT_T_SUFFIX", "G_OPT_T_TYPE", "G_OPT_T_WHERE", - "G_OPT_T_SAMPLE"}; + "G_OPT_T_SAMPLE", + "G_OPT_F_FORMAT"}; struct Option *define_standard_option(const char *name) { diff --git a/python/grass/script/utils.py b/python/grass/script/utils.py index 5180356c034..e1510b40d4d 100644 --- a/python/grass/script/utils.py +++ b/python/grass/script/utils.py @@ -19,6 +19,8 @@ from __future__ import annotations +import datetime +import json import os import shutil import locale @@ -693,3 +695,32 @@ def append_random(name, suffix_length=None, total_length=None): # The following can be shorter with random.choices from Python 3.6. suffix = "".join(random.choice(allowed_chars) for _ in range(suffix_length)) return "{name}_{suffix}".format(**locals()) + + +class TemporalJSONEncoder(json.JSONEncoder): + """Custom JSON encoder with datetime support for GRASS GIS. + + Handles serialization of datetime objects to ISO 8601 format strings. + Can be used across GRASS modules that need JSON output with temporal data. + + Example: + + .. code-block:: pycon + + >>> import json + >>> from datetime import datetime + >>> data = {"timestamp": datetime(2024, 1, 15, 10, 30)} + >>> json.dumps(data, cls=TemporalJSONEncoder) + '{"timestamp": "2024-01-15T10:30:00"}' + """ + + def default(self, obj): + """Override default JSON encoding for datetime objects. + + :param obj: Object to encode + :return: ISO 8601 formatted string for datetime objects, + or calls parent encoder for other types + """ + if isinstance(obj, datetime.datetime): + return obj.isoformat() + return super().default(obj) diff --git a/python/grass/temporal/Makefile b/python/grass/temporal/Makefile index e85507061f7..af181f6375a 100644 --- a/python/grass/temporal/Makefile +++ b/python/grass/temporal/Makefile @@ -8,7 +8,7 @@ GDIR = $(PYDIR)/grass DSTDIR = $(GDIR)/temporal DSTDIRPLY = $(DSTDIR)/ply -MODULES = ply/__init__ ply/lex ply/yacc base core abstract_dataset abstract_map_dataset abstract_space_time_dataset space_time_datasets open_stds factory gui_support list_stds register sampling metadata spatial_extent temporal_extent datetime_math temporal_granularity spatio_temporal_relationships unit_tests aggregation stds_export stds_import extract mapcalc univar_statistics temporal_topology_dataset_connector spatial_topology_dataset_connector c_libraries_interface temporal_algebra temporal_vector_algebra temporal_raster_base_algebra temporal_raster_algebra temporal_raster3d_algebra temporal_operator +MODULES = ply/__init__ ply/lex ply/yacc base core abstract_dataset abstract_map_dataset abstract_space_time_dataset space_time_datasets open_stds factory gui_support list_stds register sampling metadata spatial_extent temporal_extent datetime_math temporal_granularity spatio_temporal_relationships unit_tests aggregation stds_export stds_import extract mapcalc univar_statistics temporal_topology_dataset_connector spatial_topology_dataset_connector c_libraries_interface temporal_algebra temporal_vector_algebra temporal_raster_base_algebra temporal_raster_algebra temporal_raster3d_algebra temporal_operator utils CLEAN_SUBDIRS = ply diff --git a/python/grass/temporal/__init__.py b/python/grass/temporal/__init__.py index b70fe0ab96f..878b8248e3a 100644 --- a/python/grass/temporal/__init__.py +++ b/python/grass/temporal/__init__.py @@ -190,6 +190,7 @@ print_gridded_dataset_univar_statistics, print_vector_dataset_univar_statistics, ) +from grass.script.utils import TemporalJSONEncoder __all__ = [ "AbsoluteTemporalExtent", @@ -253,6 +254,7 @@ "TemporalAlgebraLexer", "TemporalAlgebraParser", "TemporalExtent", + "TemporalJSONEncoder", "TemporalOperatorLexer", "TemporalOperatorParser", "TemporalRaster3DAlgebraParser", diff --git a/python/grass/temporal/abstract_dataset.py b/python/grass/temporal/abstract_dataset.py index 9fbd42f2c90..f8063864d27 100644 --- a/python/grass/temporal/abstract_dataset.py +++ b/python/grass/temporal/abstract_dataset.py @@ -12,11 +12,14 @@ from __future__ import annotations +import json from abc import ABCMeta, abstractmethod from .core import get_current_mapset, get_tgis_message_interface, init_dbif from .spatial_topology_dataset_connector import SpatialTopologyDatasetConnector from .temporal_topology_dataset_connector import TemporalTopologyDatasetConnector +from grass.script.utils import TemporalJSONEncoder + ############################################################################### @@ -218,6 +221,54 @@ def print_info(self): def print_shell_info(self): """Print information about this class in shell style""" + def print_json(self) -> None: + """Print dataset metadata as JSON to stdout. + + Outputs complete metadata in JSON format by merging all internal + data dictionaries. This automatically includes all metadata fields + without manual enumeration, ensuring parity with shell output. + + The output can be parsed by any JSON-compatible tool or library. + """ + data = self._to_json_dict() + print(json.dumps(data, cls=TemporalJSONEncoder, indent=4)) + + def _to_json_dict(self): + """Build a dictionary from internal metadata storage for JSON output. + + Uses dict merging to automatically capture all metadata from the + internal D dictionaries. This approach ensures: + + - Complete metadata coverage (no missing fields) + - No manual key enumeration required + - Automatic updates when new metadata fields are added + - Parity with print_shell_info() output + + :return: Complete metadata dictionary ready for JSON serialization + """ + # Merge all internal data dictionaries. + # Each component (base, temporal_extent, spatial_extent, metadata) + # stores its data in a D dict that gets merged here. + data = {} + + # Add base metadata (id, name, mapset, creator, etc.) + if hasattr(self, "base") and hasattr(self.base, "D"): + data.update(self.base.D) + + # Add temporal extent metadata (start_time, end_time, etc.) + if hasattr(self, "temporal_extent") and hasattr(self.temporal_extent, "D"): + data.update(self.temporal_extent.D) + + # Add spatial extent metadata (north, south, east, west, etc.) + if hasattr(self, "spatial_extent") and hasattr(self.spatial_extent, "D"): + data.update(self.spatial_extent.D) + + # Add general metadata (title, description, etc.) + if hasattr(self, "metadata") and hasattr(self.metadata, "D"): + data.update(self.metadata.D) + + return data + @abstractmethod def print_self(self): """Print the content of the internal structure to stdout""" diff --git a/python/grass/temporal/abstract_map_dataset.py b/python/grass/temporal/abstract_map_dataset.py index 7e259b06970..922854a45bc 100644 --- a/python/grass/temporal/abstract_map_dataset.py +++ b/python/grass/temporal/abstract_map_dataset.py @@ -341,6 +341,24 @@ def print_shell_info(self) -> None: if self.is_topology_build(): self.print_topology_shell_info() + def _to_json_dict(self): + """Build a dictionary from internal metadata storage for JSON output. + + Extends the base implementation by adding information about + space-time datasets that this map is registered in. + + :return: Complete metadata dictionary including STDS registration info + """ + # Get base metadata from parent class (uses dict-merge approach) + data = super()._to_json_dict() + + # Add map-specific information: which STDSs is this map registered in? + datasets = self.get_registered_stds() + if datasets: + data["registered_datasets"] = sorted(list(datasets)) + + return data + def insert(self, dbif=None, execute: bool = True): """Insert the map content into the database from the internal structure diff --git a/python/grass/temporal/abstract_space_time_dataset.py b/python/grass/temporal/abstract_space_time_dataset.py index 98a6e5d9347..08bd8e69132 100644 --- a/python/grass/temporal/abstract_space_time_dataset.py +++ b/python/grass/temporal/abstract_space_time_dataset.py @@ -21,7 +21,10 @@ class that is the base class for all space time datasets. from pathlib import Path from grass.exceptions import FatalError -from .abstract_dataset import AbstractDataset, AbstractDatasetComparisonKeyStartTime +from .abstract_dataset import ( + AbstractDataset, + AbstractDatasetComparisonKeyStartTime, +) from .core import ( get_current_mapset, get_sql_template_path, @@ -178,6 +181,24 @@ def print_shell_info(self) -> None: self.spatial_extent.print_shell_info() self.metadata.print_shell_info() + def _to_json_dict(self): + """Build a dictionary from internal metadata storage for JSON output. + + Extends the base implementation by adding computed fields that are + present in shell output but not stored in the D dictionaries. + For example, semantic_labels is fetched on-the-fly via SQL. + + :return: Complete metadata dictionary with computed fields included + """ + data = super()._to_json_dict() + + # Add computed fields from metadata that are in shell output + # but not in metadata.D (fetched on-the-fly via SQL) + if hasattr(self.metadata, "get_semantic_labels"): + data["semantic_labels"] = self.metadata.get_semantic_labels() + + return data + def print_history(self) -> None: """Print history information about this class in human readable shell style diff --git a/python/grass/temporal/utils.py b/python/grass/temporal/utils.py new file mode 100644 index 00000000000..29cdd6095cb --- /dev/null +++ b/python/grass/temporal/utils.py @@ -0,0 +1,14 @@ +"""Utility functions and classes for the temporal framework. + +(C) 2025 by the GRASS Development Team +This program is free software under the GNU General Public +License (>=v2). Read the file COPYING that comes with GRASS +for details. + +:authors: GRASS Development Team +""" + +# Re-export from canonical location for backward compatibility +from grass.script.utils import TemporalJSONEncoder + +__all__ = ["TemporalJSONEncoder"] diff --git a/temporal/t.info/t.info.py b/temporal/t.info/t.info.py index c04fe0c0a5f..4bde170b420 100755 --- a/temporal/t.info/t.info.py +++ b/temporal/t.info/t.info.py @@ -38,6 +38,12 @@ # % options: strds, str3ds, stvds, raster, raster_3d, vector # %end +# %option G_OPT_F_FORMAT +# % options: plain,json,shell +# % descriptions: plain;Human readable text output;shell;Shell script style text output;json;JSON (JavaScript Object Notation) +# % guisection: Print +# %end + # %flag # % key: g # % description: Print in shell script style @@ -65,6 +71,7 @@ def main(): name = options["input"] type_ = options["type"] + format_ = options["format"] shellstyle = flags["g"] system = flags["d"] history = flags["h"] @@ -119,7 +126,9 @@ def main(): dataset.print_history() return - if shellstyle: + if format_ == "json": + dataset.print_json() + elif format_ == "shell" or shellstyle: dataset.print_shell_info() else: dataset.print_info() diff --git a/temporal/t.info/testsuite/test_t_info_json.py b/temporal/t.info/testsuite/test_t_info_json.py new file mode 100644 index 00000000000..aad6d162a25 --- /dev/null +++ b/temporal/t.info/testsuite/test_t_info_json.py @@ -0,0 +1,501 @@ +"""Test t.info format=json output for all space-time dataset types. + +(C) 2025 by the GRASS Development Team +This program is free software under the GNU General Public +License (>=v2). Read the file COPYING that comes with GRASS +for details. + +Validates that t.info format=json returns valid JSON and that +parsed output contains correct metadata values (not only keys). +Covers STRDS, STVDS, and STR3DS with shell/JSON parity checks. +""" + +import json + +import grass.script as gs +from grass.gunittest.case import TestCase +from grass.gunittest.gmodules import SimpleModule +from grass.gunittest.main import test + + +class TestTinfoJson(TestCase): + """Value-based tests for t.info format=json.""" + + @classmethod + def setUpClass(cls): + """Set region and create a space time raster dataset with maps.""" + cls.use_temp_region() + cls.runModule( + "g.region", + s=0, + n=80, + w=0, + e=120, + b=0, + t=50, + res=10, + res3=10, + flags="p3", + ) + cls.runModule("r.mapcalc", expression="prec_1 = 1", overwrite=True) + cls.runModule("r.mapcalc", expression="prec_2 = 2", overwrite=True) + cls.runModule("r.mapcalc", expression="prec_3 = 3", overwrite=True) + cls.runModule( + "t.create", + type="strds", + temporaltype="absolute", + output="precip_abs1", + title="A test", + description="A test", + overwrite=True, + ) + cls.runModule( + "t.register", + type="raster", + flags="i", + input="precip_abs1", + maps="prec_1,prec_2,prec_3", + start="2001-01-01", + increment="1 months", + overwrite=True, + ) + cls.mapset = gs.gisenv()["MAPSET"] + + @classmethod + def tearDownClass(cls): + """Remove dataset and temporary region.""" + cls.runModule("t.remove", flags="df", type="strds", inputs="precip_abs1") + cls.del_temp_region() + + def test_json_format_returns_valid_json(self): + """t.info format=json outputs valid JSON (parseable with json.loads).""" + info = SimpleModule("t.info", format="json", type="strds", input="precip_abs1") + self.assertModule(info) + out = info.outputs.stdout + self.assertIsNotNone(out) + parsed = json.loads(out) + self.assertIsInstance(parsed, dict) + + def test_json_metadata_values(self): + """Parsed JSON from t.info format=json has correct metadata values.""" + info = SimpleModule("t.info", format="json", type="strds", input="precip_abs1") + self.assertModule(info) + parsed = json.loads(info.outputs.stdout) + + self.assertEqual(parsed["name"], "precip_abs1") + self.assertEqual(parsed["mapset"], self.mapset) + self.assertEqual(parsed["id"], f"precip_abs1@{self.mapset}") + self.assertEqual(parsed["temporal_type"], "absolute") + + self.assertEqual(parsed["north"], 80) + self.assertEqual(parsed["south"], 0) + self.assertEqual(parsed["east"], 120) + self.assertEqual(parsed["west"], 0) + + self.assertEqual(parsed["number_of_maps"], 3) + self.assertEqual(parsed["title"], "A test") + self.assertEqual(parsed["description"], "A test") + + self.assertEqual(parsed["start_time"], "2001-01-01T00:00:00") + self.assertEqual(parsed["end_time"], "2001-04-01T00:00:00") + + def test_json_required_keys_present(self): + """All required metadata keys are present in JSON output.""" + info = SimpleModule("t.info", format="json", type="strds", input="precip_abs1") + self.assertModule(info) + parsed = json.loads(info.outputs.stdout) + + required_keys = [ + "id", + "name", + "mapset", + "temporal_type", + "creation_time", + "start_time", + "end_time", + "number_of_maps", + "north", + "south", + "east", + "west", + "title", + "description", + ] + + for key in required_keys: + self.assertIn( + key, + parsed, + f"Required key '{key}' missing from JSON output", + ) + + def test_json_raster_statistics_present(self): + """STRDS JSON output includes raster-specific statistics.""" + info = SimpleModule("t.info", format="json", type="strds", input="precip_abs1") + self.assertModule(info) + parsed = json.loads(info.outputs.stdout) + + expected_raster_keys = [ + "nsres_min", + "nsres_max", + "ewres_min", + "ewres_max", + "min_min", + "min_max", + "max_min", + "max_max", + "aggregation_type", + ] + + for key in expected_raster_keys: + self.assertIn( + key, + parsed, + f"STRDS should include raster statistic '{key}'", + ) + + def test_json_shell_parity(self): + """JSON output contains all keys present in shell format output.""" + # Get shell format output + shell_info = SimpleModule( + "t.info", format="shell", type="strds", input="precip_abs1" + ) + self.assertModule(shell_info) + + # Parse shell output to get keys + shell_keys = set() + for line in shell_info.outputs.stdout.strip().split("\n"): + if "=" in line: + key = line.split("=")[0] + shell_keys.add(key) + + # Get JSON output + json_info = SimpleModule( + "t.info", format="json", type="strds", input="precip_abs1" + ) + self.assertModule(json_info) + json_data = json.loads(json_info.outputs.stdout) + json_keys = set(json_data.keys()) + + # JSON should have all the keys that shell has + missing_keys = shell_keys - json_keys + self.assertEqual( + len(missing_keys), + 0, + f"JSON output missing keys from shell output: {missing_keys}", + ) + + def test_json_format_via_parser(self): + """format=json works through the parser (G_OPT_F_FORMAT integration).""" + info = SimpleModule("t.info", format="json", type="strds", input="precip_abs1") + self.assertModule(info) + out = info.outputs.stdout + self.assertTrue(len(out) > 0) + parsed = json.loads(out) + self.assertIsInstance(parsed, dict) + + def test_shell_format_via_option(self): + """format=shell works as equivalent to -g flag.""" + flag_info = SimpleModule("t.info", flags="g", type="strds", input="precip_abs1") + self.assertModule(flag_info) + + option_info = SimpleModule( + "t.info", format="shell", type="strds", input="precip_abs1" + ) + self.assertModule(option_info) + + # Both should produce the same output + self.assertEqual(flag_info.outputs.stdout, option_info.outputs.stdout) + + def test_plain_format_still_works(self): + """Default plain format output still functions correctly.""" + info = SimpleModule("t.info", format="plain", type="strds", input="precip_abs1") + self.assertModule(info) + out = info.outputs.stdout + self.assertIn("precip_abs1", out) + + +class TestTinfoJsonStvds(TestCase): + """Value-based tests for t.info format=json on STVDS.""" + + @classmethod + def setUpClass(cls): + """Set region and create a space time vector dataset with maps.""" + cls.use_temp_region() + cls.runModule( + "g.region", + s=0, + n=80, + w=0, + e=120, + b=0, + t=50, + res=10, + res3=10, + ) + cls.runModule( + "v.random", + output="vect_1", + npoints=20, + seed=1, + overwrite=True, + ) + cls.runModule( + "v.random", + output="vect_2", + npoints=20, + seed=2, + overwrite=True, + ) + cls.runModule( + "v.random", + output="vect_3", + npoints=20, + seed=3, + overwrite=True, + ) + cls.runModule( + "t.create", + type="stvds", + temporaltype="absolute", + output="vect_abs1", + title="A test STVDS", + description="Test vector time series", + overwrite=True, + ) + cls.runModule( + "t.register", + type="vector", + flags="i", + input="vect_abs1", + maps="vect_1,vect_2,vect_3", + start="2001-01-01", + increment="1 months", + overwrite=True, + ) + cls.mapset = gs.gisenv()["MAPSET"] + + @classmethod + def tearDownClass(cls): + """Remove dataset and temporary region.""" + cls.runModule("t.remove", flags="df", type="stvds", inputs="vect_abs1") + cls.del_temp_region() + + def test_json_format_returns_valid_json(self): + """t.info format=json outputs valid JSON for STVDS.""" + info = SimpleModule("t.info", format="json", type="stvds", input="vect_abs1") + self.assertModule(info) + parsed = json.loads(info.outputs.stdout) + self.assertIsInstance(parsed, dict) + + def test_json_metadata_values(self): + """Parsed JSON has correct metadata values for STVDS.""" + info = SimpleModule("t.info", format="json", type="stvds", input="vect_abs1") + self.assertModule(info) + parsed = json.loads(info.outputs.stdout) + + self.assertEqual(parsed["name"], "vect_abs1") + self.assertEqual(parsed["mapset"], self.mapset) + self.assertEqual(parsed["id"], f"vect_abs1@{self.mapset}") + self.assertEqual(parsed["temporal_type"], "absolute") + self.assertEqual(parsed["number_of_maps"], 3) + self.assertEqual(parsed["title"], "A test STVDS") + self.assertEqual(parsed["description"], "Test vector time series") + self.assertEqual(parsed["start_time"], "2001-01-01T00:00:00") + self.assertEqual(parsed["end_time"], "2001-04-01T00:00:00") + + def test_json_vector_metadata_present(self): + """STVDS JSON output includes vector-specific metadata.""" + info = SimpleModule("t.info", format="json", type="stvds", input="vect_abs1") + self.assertModule(info) + parsed = json.loads(info.outputs.stdout) + + expected_vector_keys = [ + "points", + "lines", + "boundaries", + "centroids", + "areas", + "primitives", + "number_of_maps", + ] + + for key in expected_vector_keys: + self.assertIn( + key, + parsed, + f"STVDS should include vector metadata '{key}'", + ) + + def test_json_shell_parity(self): + """JSON output contains all keys from shell format for STVDS.""" + shell_info = SimpleModule( + "t.info", + format="shell", + type="stvds", + input="vect_abs1", + ) + self.assertModule(shell_info) + + shell_keys = set() + for line in shell_info.outputs.stdout.strip().split("\n"): + if "=" in line: + shell_keys.add(line.split("=")[0]) + + json_info = SimpleModule( + "t.info", + format="json", + type="stvds", + input="vect_abs1", + ) + self.assertModule(json_info) + json_keys = set(json.loads(json_info.outputs.stdout).keys()) + + missing_keys = shell_keys - json_keys + self.assertEqual( + len(missing_keys), + 0, + f"JSON output missing keys from shell: {missing_keys}", + ) + + +class TestTinfoJsonStr3ds(TestCase): + """Value-based tests for t.info format=json on STR3DS.""" + + @classmethod + def setUpClass(cls): + """Set region and create a space time 3D raster dataset.""" + cls.use_temp_region() + cls.runModule( + "g.region", + s=0, + n=80, + w=0, + e=120, + b=0, + t=50, + res=10, + res3=10, + ) + cls.runModule("r3.mapcalc", expression="vol_1 = 100", overwrite=True) + cls.runModule("r3.mapcalc", expression="vol_2 = 200", overwrite=True) + cls.runModule("r3.mapcalc", expression="vol_3 = 300", overwrite=True) + cls.runModule( + "t.create", + type="str3ds", + temporaltype="absolute", + output="vol_abs1", + title="A test STR3DS", + description="Test 3D raster time series", + overwrite=True, + ) + cls.runModule( + "t.register", + type="raster_3d", + flags="i", + input="vol_abs1", + maps="vol_1,vol_2,vol_3", + start="2001-01-01", + increment="1 months", + overwrite=True, + ) + cls.mapset = gs.gisenv()["MAPSET"] + + @classmethod + def tearDownClass(cls): + """Remove dataset and temporary region.""" + cls.runModule("t.remove", flags="df", type="str3ds", inputs="vol_abs1") + cls.del_temp_region() + + def test_json_format_returns_valid_json(self): + """t.info format=json outputs valid JSON for STR3DS.""" + info = SimpleModule("t.info", format="json", type="str3ds", input="vol_abs1") + self.assertModule(info) + parsed = json.loads(info.outputs.stdout) + self.assertIsInstance(parsed, dict) + + def test_json_metadata_values(self): + """Parsed JSON has correct metadata values for STR3DS.""" + info = SimpleModule("t.info", format="json", type="str3ds", input="vol_abs1") + self.assertModule(info) + parsed = json.loads(info.outputs.stdout) + + self.assertEqual(parsed["name"], "vol_abs1") + self.assertEqual(parsed["mapset"], self.mapset) + self.assertEqual(parsed["id"], f"vol_abs1@{self.mapset}") + self.assertEqual(parsed["temporal_type"], "absolute") + + self.assertEqual(parsed["north"], 80) + self.assertEqual(parsed["south"], 0) + self.assertEqual(parsed["east"], 120) + self.assertEqual(parsed["west"], 0) + self.assertEqual(parsed["top"], 50) + self.assertEqual(parsed["bottom"], 0) + + self.assertEqual(parsed["number_of_maps"], 3) + self.assertEqual(parsed["title"], "A test STR3DS") + self.assertEqual(parsed["description"], "Test 3D raster time series") + self.assertEqual(parsed["start_time"], "2001-01-01T00:00:00") + self.assertEqual(parsed["end_time"], "2001-04-01T00:00:00") + + def test_json_raster3d_statistics_present(self): + """STR3DS JSON output includes 3D raster-specific statistics.""" + info = SimpleModule("t.info", format="json", type="str3ds", input="vol_abs1") + self.assertModule(info) + parsed = json.loads(info.outputs.stdout) + + expected_raster3d_keys = [ + "nsres_min", + "nsres_max", + "ewres_min", + "ewres_max", + "tbres_min", + "tbres_max", + "min_min", + "min_max", + "max_min", + "max_max", + "aggregation_type", + ] + + for key in expected_raster3d_keys: + self.assertIn( + key, + parsed, + f"STR3DS should include 3D raster statistic '{key}'", + ) + + def test_json_shell_parity(self): + """JSON output contains all keys from shell format for STR3DS.""" + shell_info = SimpleModule( + "t.info", + format="shell", + type="str3ds", + input="vol_abs1", + ) + self.assertModule(shell_info) + + shell_keys = set() + for line in shell_info.outputs.stdout.strip().split("\n"): + if "=" in line: + shell_keys.add(line.split("=")[0]) + + json_info = SimpleModule( + "t.info", + format="json", + type="str3ds", + input="vol_abs1", + ) + self.assertModule(json_info) + json_keys = set(json.loads(json_info.outputs.stdout).keys()) + + missing_keys = shell_keys - json_keys + self.assertEqual( + len(missing_keys), + 0, + f"JSON output missing keys from shell: {missing_keys}", + ) + + +if __name__ == "__main__": + test()