From 96a6bd1bc83c1da5c4e06222e11966c96d5aef0e Mon Sep 17 00:00:00 2001 From: Corey White Date: Mon, 23 Sep 2024 17:27:16 -0400 Subject: [PATCH] t.stac: Updated dependency versions, adding lazy loading (#1189) --- src/temporal/t.stac/README.md | 8 +- src/temporal/t.stac/libstac/__init__.py | 1 + src/temporal/t.stac/libstac/staclib.py | 553 +++++++++++++----- src/temporal/t.stac/requirements.txt | 6 +- .../t.stac/t.stac.catalog/t.stac.catalog.html | 117 ++-- .../t.stac/t.stac.catalog/t.stac.catalog.py | 97 +-- .../t.stac.collection/t.stac.collection.py | 86 ++- .../t.stac/t.stac.item/t.stac.item.py | 257 ++------ 8 files changed, 620 insertions(+), 505 deletions(-) diff --git a/src/temporal/t.stac/README.md b/src/temporal/t.stac/README.md index a70f113cf8..878acd426a 100644 --- a/src/temporal/t.stac/README.md +++ b/src/temporal/t.stac/README.md @@ -1,9 +1,9 @@ -# (In-Development) t.stac +# t.stac ## Description The **t.stac** toolset utilizes the -[pystac-client (v0.5.1)](https://github.com/stac-utils/pystac-client) to search +[pystac-client (v0.8.3)](https://github.com/stac-utils/pystac-client) to search STAC APIs and import items into GRASS GIS. ### Item Search Parameters @@ -83,7 +83,9 @@ required. Use items_as_dicts to avoid object unmarshalling errors. ### Dependencies -* [pystac-client (v0.5.1)](https://github.com/stac-utils/pystac-client) +* [pystac-client (v0.8.3)](https://github.com/stac-utils/pystac-client) +* [pystac (v1.10.1)](https://pystac.readthedocs.io/en/stable/) +* [tqdm (4.66.3)](https://github.com/tqdm/tqdm) #### Optional Query diff --git a/src/temporal/t.stac/libstac/__init__.py b/src/temporal/t.stac/libstac/__init__.py index e69de29bb2..aac562c450 100644 --- a/src/temporal/t.stac/libstac/__init__.py +++ b/src/temporal/t.stac/libstac/__init__.py @@ -0,0 +1 @@ +import staclib as libstac # noqa diff --git a/src/temporal/t.stac/libstac/staclib.py b/src/temporal/t.stac/libstac/staclib.py index ea609d6007..026a74bf89 100644 --- a/src/temporal/t.stac/libstac/staclib.py +++ b/src/temporal/t.stac/libstac/staclib.py @@ -1,13 +1,206 @@ -import grass.script as gs -from grass.pygrass.gis.region import Region -from grass.pygrass.vector import VectorTopo -from grass.pygrass.vector.geometry import Point, Area, Centroid, Boundary +#!/usr/bin/env python3 + +############################################################################ +# +# MODULE: staclib +# AUTHOR: Corey T. White, OpenPlains Inc. & NCSU +# PURPOSE: Helper library to import STAC data in to GRASS. +# COPYRIGHT: (C) 2024 Corey White +# This program is free software under the GNU General +# Public License (>=v2). Read the file COPYING that +# comes with GRASS for details. +# +############################################################################# + + +import os +import sys import base64 import tempfile import json -import os -from pystac_client.conformance import ConformanceClasses -from pystac_client.exceptions import APIError +from datetime import datetime +from dateutil import parser +from io import StringIO +from pprint import pprint +import grass.script as gs +from grass.exceptions import CalledModuleError +from grass.pygrass.vector import VectorTopo +from grass.pygrass.vector.geometry import Point, Centroid, Boundary +from concurrent.futures import ThreadPoolExecutor + +# Import pystac_client modules +try: + from pystac_client import Client + from pystac_client.exceptions import APIError + from pystac_client.conformance import ConformanceClasses +except ImportError as err: + gs.fatal(_("Unable to import pystac_client: {err}")) + + +def _import_tqdm(error): + """Import tqdm module""" + try: + from tqdm import tqdm + + return tqdm + except ImportError as err: + if error: + raise err + return None + + +def _import_pystac_mediatype(error): + """Import pystac module""" + try: + from pystac import MediaType + + return MediaType + except ImportError as err: + if error: + raise err + return None + + +class STACHelper: + """STAC Helper Class""" + + def __init__(self): + self.client = None + + def connect_to_stac(self, url, headers=None): + """Connect to a STAC catalog.""" + if self.client is None: + try: + self.client = Client.open(url, headers) + return self.client + except APIError as err: + gs.fatal(f"Failed to connect to STAC catalog: {err}") + else: + gs.warning(_("Client already connected.")) + return self.client + + def get_all_collections(self): + """Get a list of collections from STAC Client""" + if self.conforms_to_collections(): + gs.verbose(_("Client conforms to Collection")) + try: + collections = self.client.get_collections() + collection_list = list(collections) + return [i.to_dict() for i in collection_list] + + except APIError as e: + gs.fatal(_("Error getting collections: {}".format(e))) + + def get_collection(self, collection_id): + """Get a collection frofrom io import StringIOm STAC Client""" + try: + collection = self.client.get_collection(collection_id) + self.collection = collection.to_dict() + return self.collection + + except APIError as e: + gs.fatal(_("Error getting collection: {}".format(e))) + + def search_api(self, **kwargs): + """Search the STAC API""" + if self.conforms_to_item_search(): + gs.verbose(_("STAC API Conforms to Item Search")) + + if kwargs.get("filter"): + self.conforms_to_filter() + + if kwargs.get("query"): + self.conforms_to_query() + + try: + search = self.client.search(**kwargs) + except APIError as e: + gs.fatal(_("Error searching STAC API: {}".format(e))) + except NotImplementedError as e: + gs.fatal(_("Error searching STAC API: {}".format(e))) + except Exception as e: + gs.fatal(_("Error searching STAC API: {}".format(e))) + + try: + gs.message(_(f"Search Matched: {search.matched()} items")) + except e: + gs.warning(_(f"No items found: {e}")) + return None + + return search + + def report_stac_item(self, item): + """Print a report of the STAC item to the console.""" + sys.stdout.write(f"Collection ID: {item.collection_id}\n") + sys.stdout.write(f"Item: {item.id}\n") + print_attribute(item, "geometry", "Geometry") + sys.stdout.write(f"Bbox: {item.bbox}\n") + + print_attribute(item, "datetime", "Datetime") + print_attribute(item, "start_datetime", "Start Datetime") + print_attribute(item, "end_datetime", "End Datetime") + sys.stdout.write("Extra Fields:\n") + print_summary(item.extra_fields) + + print_list_attribute(item.stac_extensions, "Extensions:") + # libstac.print_attribute(it_import_tqdmem, "stac_extensions", "Extensions") + sys.stdout.write("Properties:\n") + print_summary(item.properties) + + def _check_conformance(self, conformance_class, response="fatal"): + """Check if the STAC API conforms to the given conformance class""" + if not self.client.conforms_to(conformance_class): + if response == "fatal": + gs.fatal(_(f"STAC API does not conform to {conformance_class}")) + return False + elif response == "warning": + gs.warning(_(f"STAC API does not conform to {conformance_class}")) + return True + elif response == "verbose": + gs.verbose(_(f"STAC API does not conform to {conformance_class}")) + return True + elif response == "info": + gs.info(_(f"STAC API does not conform to {conformance_class}")) + return True + elif response == "message": + sys.stdout.write(f"STAC API does not conform to {conformance_class}\n") + return True + + def conforms_to_collections(self): + """Check if the STAC API conforms to the Collections conformance class""" + return self._check_conformance( + ConformanceClasses.COLLECTIONS, response="warning" + ) + + def conforms_to_item_search(self): + """Check if the STAC API conforms to the Item Search conformance class""" + return self._check_conformance( + ConformanceClasses.ITEM_SEARCH, response="warning" + ) + + def conforms_to_filter(self): + """Check if the STAC API conforms to the Filter conformance class""" + return self._check_conformance(ConformanceClasses.FILTER, response="warning") + + def conforms_to_query(self): + """Check if the STAC API conforms to the Query conformance class""" + return self._check_conformance(ConformanceClasses.QUERY, response="warning") + + def conforms_to_sort(self): + """Check if the STAC API conforms to the Sort conformance class""" + return self._check_conformance(ConformanceClasses.SORT, response="warning") + + def conforms_to_fields(self): + """Check if the STAC API conforms to the Fields conformance class""" + return self._check_conformance(ConformanceClasses.FIELDS, response="warning") + + def conforms_to_core(self): + """Check if the STAC API conforms to the Core conformance class""" + return self._check_conformance(ConformanceClasses.CORE, response="warning") + + def conforms_to_context(self): + """Check if the STAC API conforms to the Context conformance class""" + return self._check_conformance(ConformanceClasses.CONTEXT, response="warning") def encode_credentials(username, password): @@ -63,81 +256,157 @@ def print_summary(data, depth=1): for key, value in data.items(): indentation = generate_indentation(start_depth) if isinstance(value, dict): - gs.message(_(f"#\n# {indentation}{key}:")) + sys.stdout.write(f"{'-' * 75}\n") + sys.stdout.write(f"\n {indentation}{key}:\n") print_summary(value, depth=start_depth + 1) if isinstance(value, list): - gs.message(_(f"# {indentation}{key}:")) + sys.stdout.write(f"{'-' * 75}\n") + sys.stdout.write(f"{indentation}{key}:\n") for item in value: if isinstance(item, dict): print_summary(item, depth=start_depth + 1) else: - gs.message(_(f"# {indentation}{key}: {value}")) + sys.stdout.write(f"# {indentation}{key}: {value}\n") + + +def print_json_to_stdout(data, pretty=False): + """Pretty print data to stdout""" + if pretty: + output = StringIO() + pprint(data, stream=output) + sys.stdout.write(output.getvalue()) + else: + json_output = json.dumps(data) + sys.stdout.write(json_output) def print_list_attribute(data, title): "Print a list attribute" - gs.message(_(f"{title}")) + sys.stdout.write(f"{'-' * 75}\n") + sys.stdout.write(f"{title}\n") + sys.stdout.write(f"{'-' * 75}\n") for item in data: - gs.message(_(f"\t{item}")) + sys.stdout.write(f"\t{item}\n") + sys.stdout.write(f"{'-' * 75}\n") def print_attribute(item, attribute, message=None): """Print an attribute of the item and handle AttributeError.""" message = message if message else attribute.capitalize() try: - gs.message(_(f"{message}: {getattr(item, attribute)}")) + sys.stdout.write(f"{message}: {getattr(item, attribute)}\n") except AttributeError: gs.info(_(f"{message} not found.")) def print_basic_collection_info(collection): """Print basic information about a collection""" - gs.message(_(f"Collection ID: {collection.get('id')}")) - gs.message(_(f"STAC Version: {collection.get('stac_version')}")) - gs.message(_(f"Description: {collection.get('description')}")) - gs.message(_(f"Extent: {collection.get('extent')}")) - gs.message(_(f"License: {collection.get('license')}")) - gs.message(_(f"Keywords: {collection.get('keywords')}")) + sys.stdout.write(f"Collection ID: {collection.get('id')}\n") + sys.stdout.write(f"STAC Version: {collection.get('stac_version')}\n") + sys.stdout.write(f"Description: {collection.get('description')}\n") + sys.stdout.write(f"Extent: {collection.get('extent')}\n") + sys.stdout.write(f"License: {collection.get('license')}\n") + sys.stdout.write(f"Keywords: {collection.get('keywords')}\n") item_summary = collection.get("summaries") - gs.message(_(f"{'-' * 75}\n")) + sys.stdout.write(f"{'-' * 75}\n\n") if item_summary: - gs.message(_("Summary:")) + sys.stdout.write("Summary:\n") for k, v in item_summary.items(): - gs.message(_(f"{k}: {v}")) - gs.message(_(f"{'-' * 75}\n")) + sys.stdout.write(f"{k}: {v}\n") + sys.stdout.write(f"{'-' * 75}\n\n") item_assets = collection.get("item_assets") item_asset_keys = item_assets.keys() - gs.message(_(f"Item Assets Keys: {list(item_asset_keys)}")) - gs.message(_(f"{'-' * 75}\n")) + sys.stdout.write(f"Item Assets Keys: {list(item_asset_keys)}\n") + sys.stdout.write(f"{'-' * 75}\n\n") for key, value in item_assets.items(): - gs.message(_(f"Asset: {value.get('title')}")) - gs.message(_(f"Key: {key}")) - gs.message(_(f"Roles: {value.get('roles')}")) - gs.message(_(f"Type: {value.get('type')}")) - gs.message(_(f"Description: {value.get('description')}")) + sys.stdout.write(f"Asset: {value.get('title')}\n") + sys.stdout.write(f"Key: {key}\n") + sys.stdout.write(f"Roles: {value.get('roles')}\n") + sys.stdout.write(f"Type: {value.get('type')}\n") + sys.stdout.write(f"Description: {value.get('description')}\n") if value.get("gsd"): - gs.message(_(f"GSD: {value.get('gsd')}")) + sys.stdout.write(f"GSD: {value.get('gsd')}\n") if value.get("eo:bands"): - gs.message(_("EO Bands:")) + sys.stdout.write("EO Bands:\n") for band in value.get("eo:bands"): - gs.message(_(f"Band: {band}")) + sys.stdout.write(f"Band: {band}\n") if value.get("proj:shape"): - gs.message(_(f"Shape: {value.get('proj:shape')}")) + sys.stdout.write(f"Shape: {value.get('proj:shape')}\n") if value.get("proj:transform"): - gs.message(_(f"Asset Transform: {value.get('proj:transform')}")) + sys.stdout.write(f"Asset Transform: {value.get('proj:transform')}\n") if value.get("proj:crs"): - gs.message(_(f"CRS: {value.get('proj:crs')}")) + sys.stdout.write(f"CRS: {value.get('proj:crs')}\n") if value.get("proj:geometry"): - gs.message(_(f"Geometry: {value.get('proj:geometry')}")) + sys.stdout.write(f"Geometry: {value.get('proj:geometry')}\n") if value.get("proj:extent"): - gs.message(_(f"Asset Extent: {value.get('proj:extent')}")) + sys.stdout.write(f"Asset Extent: {value.get('proj:extent')}\n") if value.get("raster:bands"): - gs.message(_("Raster Bands:")) + sys.stdout.write("Raster Bands:\n") for band in value.get("raster:bands"): - gs.message(_(f"Band: {band}")) + sys.stdout.write(f"Band: {band}\n") + + sys.stdout.write(f"{'-' * 75}\n\n") + + +def collection_metadata(collection): + """Get collection""" + + sys.stdout.write(f"{'-' * 75}\n\n") + sys.stdout.write(f"Collection Id: {collection.get('id')}\n") + sys.stdout.write(f"Title: {collection.get('title')}\n") + sys.stdout.write(f"Description: {collection.get('description')}\n") + + extent = collection.get("extent") + if extent: + spatial = extent.get("spatial") + if spatial: + bbox = spatial.get("bbox") + if bbox: + sys.stdout.write(f"bbox: {bbox}\n") + temporal = extent.get("temporal") + if temporal: + interval = temporal.get("interval") + if interval: + sys.stdout.write(f"Temporal Interval: {interval}\n") + + sys.stdout.write(f"License: {collection.get('license')}\n") + sys.stdout.write(f"Keywords: {collection.get('keywords')}\n") + # sys.stdout.write(f"Providers: {collection.get('providers')}\n") + sys.stdout.write(f"Links: {collection.get('links')}\n") + sys.stdout.write(f"Stac Extensions: {collection.get('stac_extensions')}\n") - gs.message(_(f"{'-' * 75}\n")) + try: + sys.stdout.write("\n# Summaries:\n") + print_summary(collection.get("summaries")) + except AttributeError: + gs.info(_("Summaries not found.")) + + try: + sys.stdout.write("\n# Extra Fields:\n") + print_summary(collection.get("extra_fields")) + except AttributeError: + gs.info(_("# Extra Fields not found.")) + sys.stdout.write(f"{'-' * 75}\n\n") + + +def report_plain_asset_summary(asset): + MediaType = _import_pystac_mediatype(False) + sys.stdout.write("\nAsset\n") + sys.stdout.write(f"Asset Item Id: {asset.get('item_id')}\n") + + sys.stdout.write(f"Asset Title: {asset.get('title')}\n") + sys.stdout.write(f"Asset Filename: {asset.get('file_name')}\n") + sys.stdout.write(f"raster:bands: {asset.get('raster:bands')}\n") + sys.stdout.write(f"eo:bands: {asset.get('eo:bands')}\n") + sys.stdout.write(f"Asset Description: {asset.get('description')}\n") + + if MediaType: + sys.stdout.write(f"Asset Media Type: { MediaType(asset.get('type')).name}\n") + else: + sys.stdout.write(f"Asset Media Type: {asset.get('type')}\n") + sys.stdout.write(f"Asset Roles: {asset.get('roles')}\n") + sys.stdout.write(f"Asset Href: {asset.get('href')}\n") def region_to_wgs84_decimal_degrees_bbox(): @@ -147,7 +416,6 @@ def region_to_wgs84_decimal_degrees_bbox(): float(c) for c in [region["ll_w"], region["ll_s"], region["ll_e"], region["ll_n"]] ] - gs.message(_("BBOX: {}".format(bbox))) return bbox @@ -178,7 +446,7 @@ def check_url_type(url): gs.warning(_("HTTP is not secure. Using HTTPS instead.")) return url.replace("https://", "/vsicurl/https://") else: - gs.message(_(f"Unknown Protocol: {url}")) + sys.stdout.write(f"Unknown Protocol: {url}\n") return "unknown" @@ -289,25 +557,12 @@ def _flatten_dict(d, parent_key="", sep="_"): def create_vector_from_feature_collection(vector, search, limit, max_items): """Create a vector from items in a Feature Collection""" - n_matched = None - try: - n_matched = search.matched() - except Exception: - gs.verbose(_("STAC API doesn't support matched() method.")) - - if n_matched: - pages = (n_matched // max_items) + 1 - else: - # These requests tend to be very slow - pages = len(list(search.pages())) - - gs.message(_(f"Fetching items {n_matched} from {pages} pages.")) feature_collection = {"type": "FeatureCollection", "features": []} # Extract asset information for each item - for page in range(pages): - temp_features = search.item_collection_as_dict() + for page in search.pages_as_dicts(): + temp_features = page for idx, item in enumerate(temp_features["features"]): flattened_assets = _flatten_dict( item["assets"], parent_key="assets", sep="." @@ -330,18 +585,40 @@ def create_vector_from_feature_collection(vector, search, limit, max_items): gs.run_command("v.colors", map=vector, color="random", quiet=True) +def format_datetime(dt_str): + # Parse the datetime string + dt = parser.parse(dt_str) + # Format the datetime object to the desired format + return dt.strftime("%Y-%m-%d %H:%M:%S") + + def register_strds_from_items(collection_items_assets, strds_output): """Create registy for STRDS from collection items assets""" + with open(strds_output, "w") as f: for asset in collection_items_assets: semantic_label = asset.get("file_name").split(".")[-1] created_date = asset.get("datetime") - - if created_date: - f.write(f"{asset['file_name']}|{created_date}|{semantic_label}\n") + eobands = asset.get("eo:bands") + if eobands: + for idx, band in enumerate(eobands): + + band_name = band.get("common_name") + if created_date: + formatted_date = format_datetime(created_date) + f.write( + f"{asset['file_name']}.{idx + 1}|{formatted_date}|{band_name}\n" + ) + else: + gs.warning(_("No datetime found for item.")) + f.write(f"{asset['file_name']}.{idx + 1}|{None}|{band_name}\n") else: - gs.warning(_("No datetime found for item.")) - f.write(f"{asset['file_name']}|{None}|{semantic_label}\n") + if created_date: + formatted_date = format_datetime(created_date) + f.write(f"{asset['file_name']}|{formatted_date}|{semantic_label}\n") + else: + gs.warning(_("No datetime found for item.")) + f.write(f"{asset['file_name']}|{None}|{semantic_label}\n") def fetch_items_with_pagination(items_search, limit, max_items): @@ -400,7 +677,7 @@ def create_metadata_vector(vector, metadata): ) as new_vec: for i, item in enumerate(metadata): - gs.message(_("Adding collection: {}".format(item.get("id")))) + sys.stdout.write(f"Adding collection: {item.get('id')}\n") # Transform bbox to locations CRS # Safe extraction extent = item.get("extent", {}) @@ -418,9 +695,7 @@ def create_metadata_vector(vector, metadata): if bbox_list and isinstance(bbox_list[0], list) and len(bbox_list[0]) == 4: wgs84_bbox = bbox_list[0] else: - gs.warning( - _("Invalid bbox. Skipping Collection {}.".format(item.get("id"))) - ) + gs.warning(_(f"Invalid bbox. Skipping Collection {item.get('id')}.\n")) continue bbox = wgs84_bbox_to_boundary(wgs84_bbox) @@ -466,74 +741,78 @@ def create_metadata_vector(vector, metadata): return metadata -def get_all_collections(client): - """Get a list of collections from STAC Client""" - if conform_to_collections(client): - gs.verbose(_("Client conforms to Collection")) - try: - collections = client.get_collections() - collection_list = list(collections) - return [i.to_dict() for i in collection_list] - - except APIError as e: - gs.fatal(_("Error getting collections: {}".format(e))) - - -def _check_conformance(client, conformance_class, response="fatal"): - """Check if the STAC API conforms to the given conformance class""" - if not client.conforms_to(conformance_class): - if response == "fatal": - gs.fatal(_(f"STAC API does not conform to {conformance_class}")) - return False - elif response == "warning": - gs.warning(_(f"STAC API does not conform to {conformance_class}")) - return True - elif response == "verbose": - gs.verbose(_(f"STAC API does not conform to {conformance_class}")) - return True - elif response == "info": - gs.info(_(f"STAC API does not conform to {conformance_class}")) - return True - elif response == "message": - gs.message(_(f"STAC API does not conform to {conformance_class}")) - return True - - -def conform_to_collections(client): - """Check if the STAC API conforms to the Collections conformance class""" - return _check_conformance(client, ConformanceClasses.COLLECTIONS) - - -def conform_to_item_search(client): - """Check if the STAC API conforms to the Item Search conformance class""" - return _check_conformance(client, ConformanceClasses.ITEM_SEARCH) - - -def conform_to_filter(client): - """Check if the STAC API conforms to the Filter conformance class""" - return _check_conformance(client, ConformanceClasses.FILTER) - - -def conform_to_query(client): - """Check if the STAC API conforms to the Query conformance class""" - return _check_conformance(client, ConformanceClasses.QUERY) +def import_grass_raster(params): + assets, resample_method, extent, resolution, resolution_value, memory = params + sys.stdout.write(f"Downloading Asset: {assets}\n") + input_url = check_url_type(assets["href"]) + sys.stdout.write(f"Import Url: {input_url}\n") - -def conform_to_sort(client): - """Check if the STAC API conforms to the Sort conformance class""" - return _check_conformance(client, ConformanceClasses.SORT) - - -def conform_to_fields(client): - """Check if the STAC API conforms to the Fields conformance class""" - return _check_conformance(client, ConformanceClasses.FIELDS) - - -def conform_to_core(client): - """Check if the STAC API conforms to the Core conformance class""" - return _check_conformance(client, ConformanceClasses.CORE) - - -def conform_to_context(client): - """Check if the STAC API conforms to the Context conformance class""" - return _check_conformance(client, ConformanceClasses.CONTEXT) + try: + sys.stdout.write(f"Importing: {assets['file_name']}\n") + gs.parse_command( + "r.import", + input=input_url, + output=assets["file_name"], + resample=resample_method, + extent=extent, + resolution=resolution, + resolution_value=resolution_value, + title=assets["file_name"], + memory=memory, + quiet=True, + ) + except CalledModuleError as e: + gs.fatal(_("Error importing raster: {}".format(e.stderr))) + + +def download_assets( + assets, + resample_method, + resample_extent, + resolution, + resolution_value, + memory=300, + nprocs=1, +): + """Downloads a list of images from the given URLs to the given filenames.""" + number_of_assets = len(assets) + resample_extent_list = [resample_extent] * number_of_assets + resolution_list = [resolution] * number_of_assets + resolution_value_list = [resolution_value] * number_of_assets + resample_method_list = [resample_method] * number_of_assets + memory_list = [memory] * number_of_assets + max_cpus = os.cpu_count() - 1 + if nprocs > max_cpus: + gs.warning( + _( + "Number of processes {nprocs} is greater than the number of CPUs {max_cpus}." + ) + ) + nprocs = max_cpus + + def execute_import_grass_raster(pbar=None): + with ThreadPoolExecutor(max_workers=nprocs) as executor: + try: + for _a in executor.map( + import_grass_raster, + zip( + assets, + resample_method_list, + resample_extent_list, + resolution_list, + resolution_value_list, + memory_list, + ), + ): + if pbar: + pbar.update(1) + except Exception as e: + gs.fatal(_("Error importing raster: {}".format(str(e)))) + + tqdm = _import_tqdm(False) + if tqdm is None: + gs.warning(_("tqdm module not found. Progress bar will not be displayed.")) + execute_import_grass_raster() + else: + with tqdm(total=number_of_assets, desc="Downloading assets") as pbar: + execute_import_grass_raster(pbar) diff --git a/src/temporal/t.stac/requirements.txt b/src/temporal/t.stac/requirements.txt index 36c2bb3e7f..53013059fe 100644 --- a/src/temporal/t.stac/requirements.txt +++ b/src/temporal/t.stac/requirements.txt @@ -1,3 +1,3 @@ -pystac==1.10 -pystac_client==0.8 -tqdm==4.66 +pystac==1.10.1 +pystac_client==0.8.3 +tqdm==4.66.3 diff --git a/src/temporal/t.stac/t.stac.catalog/t.stac.catalog.html b/src/temporal/t.stac/t.stac.catalog/t.stac.catalog.html index f58d0e9c60..0a5474c115 100644 --- a/src/temporal/t.stac/t.stac.catalog/t.stac.catalog.html +++ b/src/temporal/t.stac/t.stac.catalog/t.stac.catalog.html @@ -9,10 +9,7 @@

DESCRIPTION

REQUIREMENTS

EXAMPLES

@@ -27,10 +24,9 @@

STAC Catalog JSON metadata

GRASS Jupyter Notebooks can be used to visualize the catalog metadata.

-    from grass import gs
-    catalog = gs.parse_command('t.stac.catalog', url="https://earth-search.aws.element84.com/v1/")
-
-    print(catalog)
+from grass import gs
+catalog = gs.parse_command("t.stac.catalog", url="https://earth-search.aws.element84.com/v1/", flags="p")
+print(catalog)
 
     # Output
     {'conformsTo': ['https://api.stacspec.org/v1.0.0/core',
@@ -56,76 +52,20 @@ 

STAC Catalog JSON metadata

STAC Catalog plain text metadata


-t.stac.catalog url=https://earth-search.aws.element84.com/v1/ format=plain
-
-# Output
-    Client Id: earth-search-aws
-    Client Title: Earth Search by Element 84
-    Client Description: A STAC API of public datasets on AWS
-    Client STAC Extensions: []
-    Client Extra Fields: {'type': 'Catalog', 'conformsTo': ['https://api.stacspec.org/v1.0.0/core', 'https://api.stacspec.org/v1.0.0/collections', 'https://api.stacspec.org/v1.0.0/ogcapi-features', 'https://api.stacspec.org/v1.0.0/item-search', 'https://api.stacspec.org/v1.0.0/ogcapi-features#fields', 'https://api.stacspec.org/v1.0.0/ogcapi-features#sort', 'https://api.stacspec.org/v1.0.0/ogcapi-features#query', 'https://api.stacspec.org/v1.0.0/item-search#fields', 'https://api.stacspec.org/v1.0.0/item-search#sort', 'https://api.stacspec.org/v1.0.0/item-search#query', 'https://api.stacspec.org/v0.3.0/aggregation', 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core', 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30', 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson']}
-    Client catalog_type: ABSOLUTE_PUBLISHED
-    ---------------------------------------------------------------------------
-    Collections: 9
-    sentinel-2-pre-c1-l2a: Sentinel-2 Pre-Collection 1 Level-2A
-    Sentinel-2 Pre-Collection 1 Level-2A (baseline < 05.00), with data and metadata matching collection sentinel-2-c1-l2a
-    Extent: {'spatial': {'bbox': [[-180, -90, 180, 90]]}, 'temporal': {'interval': [['2015-06-27T10:25:31.456000Z', None]]}}
-    License: proprietary
-    ---------------------------------------------------------------------------
-    cop-dem-glo-30: Copernicus DEM GLO-30
-    The Copernicus DEM is a Digital Surface Model (DSM) which represents the surface of the Earth including buildings, infrastructure and vegetation. GLO-30 Public provides limited worldwide coverage at 30 meters because a small subset of tiles covering specific countries are not yet released to the public by the Copernicus Programme.
-    Extent: {'spatial': {'bbox': [[-180, -90, 180, 90]]}, 'temporal': {'interval': [['2021-04-22T00:00:00Z', '2021-04-22T00:00:00Z']]}}
-    License: proprietary
-    ---------------------------------------------------------------------------
-    naip: NAIP: National Agriculture Imagery Program
-    The [National Agriculture Imagery Program](https://www.fsa.usda.gov/programs-and-services/aerial-photography/imagery-programs/naip-imagery/) (NAIP) provides U.S.-wide, high-resolution aerial imagery, with four spectral bands (R, G, B, IR).  NAIP is administered by the [Aerial Field Photography Office](https://www.fsa.usda.gov/programs-and-services/aerial-photography/) (AFPO) within the [US Department of Agriculture](https://www.usda.gov/) (USDA).  Data are captured at least once every three years for each state.  This dataset represents NAIP data from 2010-present, in [cloud-optimized GeoTIFF](https://www.cogeo.org/) format.
-    Extent: {'spatial': {'bbox': [[-160, 17, -67, 50]]}, 'temporal': {'interval': [['2010-01-01T00:00:00Z', '2022-12-31T00:00:00Z']]}}
-    License: proprietary
-    ---------------------------------------------------------------------------
-    cop-dem-glo-90: Copernicus DEM GLO-90
-    The Copernicus DEM is a Digital Surface Model (DSM) which represents the surface of the Earth including buildings, infrastructure and vegetation. GLO-90 provides worldwide coverage at 90 meters.
-    Extent: {'spatial': {'bbox': [[-180, -90, 180, 90]]}, 'temporal': {'interval': [['2021-04-22T00:00:00Z', '2021-04-22T00:00:00Z']]}}
-    License: proprietary
-    ---------------------------------------------------------------------------
-    landsat-c2-l2: Landsat Collection 2 Level-2
-    Atmospherically corrected global Landsat Collection 2 Level-2 data from the Thematic Mapper (TM) onboard Landsat 4 and 5, the Enhanced Thematic Mapper Plus (ETM+) onboard Landsat 7, and the Operational Land Imager (OLI) and Thermal Infrared Sensor (TIRS) onboard Landsat 8 and 9.
-    Extent: {'spatial': {'bbox': [[-180, -90, 180, 90]]}, 'temporal': {'interval': [['1982-08-22T00:00:00Z', None]]}}
-    License: proprietary
-    ---------------------------------------------------------------------------
-    sentinel-2-l2a: Sentinel-2 Level-2A
-    Global Sentinel-2 data from the Multispectral Instrument (MSI) onboard Sentinel-2
-    Extent: {'spatial': {'bbox': [[-180, -90, 180, 90]]}, 'temporal': {'interval': [['2015-06-27T10:25:31.456000Z', None]]}}
-    License: proprietary
-    ---------------------------------------------------------------------------
-    sentinel-2-l1c: Sentinel-2 Level-1C
-    Global Sentinel-2 data from the Multispectral Instrument (MSI) onboard Sentinel-2
-    Extent: {'spatial': {'bbox': [[-180, -90, 180, 90]]}, 'temporal': {'interval': [['2015-06-27T10:25:31.456000Z', None]]}}
-    License: proprietary
-    ---------------------------------------------------------------------------
-    sentinel-2-c1-l2a: Sentinel-2 Collection 1 Level-2A
-    Sentinel-2 Collection 1 Level-2A, data from the Multispectral Instrument (MSI) onboard Sentinel-2
-    Extent: {'spatial': {'bbox': [[-180, -90, 180, 90]]}, 'temporal': {'interval': [['2015-06-27T10:25:31.456000Z', None]]}}
-    License: proprietary
-    ---------------------------------------------------------------------------
-    sentinel-1-grd: Sentinel-1 Level-1C Ground Range Detected (GRD)
-    Sentinel-1 is a pair of Synthetic Aperture Radar (SAR) imaging satellites launched in 2014 and 2016 by the European Space Agency (ESA). Their 6 day revisit cycle and ability to observe through clouds makes this dataset perfect for sea and land monitoring, emergency response due to environmental disasters, and economic applications. This dataset represents the global Sentinel-1 GRD archive, from beginning to the present, converted to cloud-optimized GeoTIFF format.
-    Extent: {'spatial': {'bbox': [[-180, -90, 180, 90]]}, 'temporal': {'interval': [['2014-10-10T00:28:21Z', None]]}}
-    License: proprietary
-    ---------------------------------------------------------------------------
-
+t.stac.catalog url=https://earth-search.aws.element84.com/v1/ format=plain -b -

Basic STAC catalog metadata

-

-    t.stac.catalog url=https://earth-search.aws.element84.com/v1/ format=plain -b
+---------------------------------------------------------------------------
+Catalog: Earth Search by Element 84
+---------------------------------------------------------------------------
 Client Id: earth-search-aws
-Client Title: Earth Search by Element 84
 Client Description: A STAC API of public datasets on AWS
 Client STAC Extensions: []
-Client Extra Fields: {'type': 'Catalog', 'conformsTo': ['https://api.stacspec.org/v1.0.0/core', 'https://api.stacspec.org/v1.0.0/collections', 'https://api.stacspec.org/v1.0.0/ogcapi-features', 'https://api.stacspec.org/v1.0.0/item-search', 'https://api.stacspec.org/v1.0.0/ogcapi-features#fields', 'https://api.stacspec.org/v1.0.0/ogcapi-features#sort', 'https://api.stacspec.org/v1.0.0/ogcapi-features#query', 'https://api.stacspec.org/v1.0.0/item-search#fields', 'https://api.stacspec.org/v1.0.0/item-search#sort', 'https://api.stacspec.org/v1.0.0/item-search#query', 'https://api.stacspec.org/v0.3.0/aggregation', 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core', 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30', 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson']}
 Client catalog_type: ABSOLUTE_PUBLISHED
 ---------------------------------------------------------------------------
 Collections: 9
 ---------------------------------------------------------------------------
+Collection Id | Collection Title
+---------------------------------------------------------------------------
 sentinel-2-pre-c1-l2a: Sentinel-2 Pre-Collection 1 Level-2A
 cop-dem-glo-30: Copernicus DEM GLO-30
 naip: NAIP: National Agriculture Imagery Program
@@ -135,7 +75,42 @@ 

Basic STAC catalog metadata

sentinel-2-l1c: Sentinel-2 Level-1C sentinel-2-c1-l2a: Sentinel-2 Collection 1 Level-2A sentinel-1-grd: Sentinel-1 Level-1C Ground Range Detected (GRD) +--------------------------------------------------------------------------- +
+ +

Basic STAC catalog metadata

+

+t.stac.catalog url=https://earth-search.aws.element84.com/v1/ format=plain
 
+---------------------------------------------------------------------------
+Catalog: Earth Search by Element 84
+---------------------------------------------------------------------------
+Client Id: earth-search-aws
+Client Description: A STAC API of public datasets on AWS
+Client STAC Extensions: []
+Client catalog_type: ABSOLUTE_PUBLISHED
+---------------------------------------------------------------------------
+Collections: 9
+---------------------------------------------------------------------------
+Collection: Sentinel-2 Pre-Collection 1 Level-2A
+---------------------------------------------------------------------------
+Collection Id: sentinel-2-pre-c1-l2a
+Sentinel-2 Pre-Collection 1 Level-2A (baseline < 05.00), with data and metadata matching collection sentinel-2-c1-l2a
+Extent: {'spatial': {'bbox': [[-180, -90, 180, 90]]}, 'temporal': {'interval': [['2015-06-27T10:25:31.456000Z', None]]}}
+License: proprietary
+---------------------------------------------------------------------------
+---------------------------------------------------------------------------
+Collection: Copernicus DEM GLO-30
+---------------------------------------------------------------------------
+Collection Id: cop-dem-glo-30
+The Copernicus DEM is a Digital Surface Model (DSM) which represents the surface of the Earth including buildings, infrastructure and vegetation. GLO-30 Public provides limited worldwide coverage at 30 meters because a small subset of tiles covering specific countries are not yet released to the public by the Copernicus Programme.
+Extent: {'spatial': {'bbox': [[-180, -90, 180, 90]]}, 'temporal': {'interval': [['2021-04-22T00:00:00Z', '2021-04-22T00:00:00Z']]}}
+License: proprietary
+---------------------------------------------------------------------------
+...
+Extent: {'spatial': {'bbox': [[-180, -90, 180, 90]]}, 'temporal': {'interval': [['2014-10-10T00:28:21Z', None]]}}
+License: proprietary
+---------------------------------------------------------------------------
 

AUTHENTICATION

@@ -143,8 +118,6 @@

AUTHENTICATION

The t.stac.catalog tool supports authentication with the STAC API using the GDAL's virtual fie system /vsi/. - -

Basic Authentication


     t.stac.catalog url="https://earth-search.aws.element84.com/v1/" settings="user:password"
diff --git a/src/temporal/t.stac/t.stac.catalog/t.stac.catalog.py b/src/temporal/t.stac/t.stac.catalog/t.stac.catalog.py
index 0c388d6d66..b69ad0304a 100644
--- a/src/temporal/t.stac/t.stac.catalog/t.stac.catalog.py
+++ b/src/temporal/t.stac/t.stac.catalog/t.stac.catalog.py
@@ -63,26 +63,44 @@
 # % description: Return basic information only
 # %end
 
+# %flag
+# % key: p
+# % description: Pretty print the JSON output
+# %end
+
 import sys
+import json
+from io import StringIO
+from contextlib import contextmanager
 from pprint import pprint
 import grass.script as gs
 from grass.pygrass.utils import get_lib_path
 
-# Import STAC Client
-from pystac_client import Client
-from pystac_client.exceptions import APIError
-import json
-
 
-path = get_lib_path(modname="t.stac", libname="staclib")
-if path is None:
-    gs.fatal("Not able to find the stac library directory.")
-sys.path.append(path)
+@contextmanager
+def add_sys_path(new_path):
+    """Add a path to sys.path and remove it when done"""
+    original_sys_path = sys.path[:]
+    sys.path.append(new_path)
+    try:
+        yield
+    finally:
+        sys.path = original_sys_path
 
 
 def main():
     """Main function"""
-    import staclib as libstac
+
+    # Import dependencies
+    path = get_lib_path(modname="t.stac", libname="staclib")
+    if path is None:
+        gs.fatal("Not able to find the stac library directory.")
+
+    with add_sys_path(path):
+        try:
+            import staclib as libstac
+        except ImportError as err:
+            gs.fatal(f"Unable to import staclib: {err}")
 
     # STAC Client options
     client_url = options["url"]  # required
@@ -90,54 +108,57 @@ def main():
 
     # Flag options
     basic_info = flags["b"]  # optional
+    pretty_print = flags["p"]  # optional
 
     # Set the request headers
     settings = options["settings"]
     req_headers = libstac.set_request_headers(settings)
 
     try:
-        client = Client.open(client_url, headers=req_headers)
-
-        # Check if the client conforms to the STAC Item Search
-        # This will exit the program if the client does not conform
-        libstac.conform_to_item_search(client)
+        stac_helper = libstac.STACHelper()
+        client = stac_helper.connect_to_stac(client_url, req_headers)
 
         if format == "plain":
-            gs.message(_(f"Client Id: {client.id}"))
-            gs.message(_(f"Client Title: {client.title}"))
-            gs.message(_(f"Client Description: {client.description}"))
-            gs.message(_(f"Client STAC Extensions: {client.stac_extensions}"))
-            gs.message(_(f"Client Extra Fields: {client.extra_fields}"))
-            gs.message(_(f"Client catalog_type: {client.catalog_type}"))
-            gs.message(_(f"{'-' * 75}\n"))
+            sys.stdout.write(f"{'-' * 75}\n")
+            sys.stdout.write(f"Catalog: {client.title}\n")
+            sys.stdout.write(f"{'-' * 75}\n")
+            sys.stdout.write(f"Client Id: {client.id}\n")
+            sys.stdout.write(f"Client Description: {client.description}\n")
+            sys.stdout.write(f"Client STAC Extensions: {client.stac_extensions}\n")
+            sys.stdout.write(f"Client catalog_type: {client.catalog_type}\n")
+            sys.stdout.write(f"{'-' * 75}\n")
 
             # Get all collections
-            collection_list = libstac.get_all_collections(client)
-            gs.message(_(f"Collections: {len(collection_list)}\n"))
-            gs.message(_(f"{'-' * 75}\n"))
+            collection_list = stac_helper.get_all_collections()
+            sys.stdout.write(f"Collections: {len(collection_list)}\n")
+            sys.stdout.write(f"{'-' * 75}\n")
 
             if basic_info:
+                sys.stdout.write("Collection Id | Collection Title\n")
+                sys.stdout.write(f"{'-' * 75}\n")
                 for i in collection_list:
-                    gs.message(_(f"{i.get('id')}: {i.get('title')}"))
-
-            if not basic_info:
+                    sys.stdout.write(f"{i.get('id')}: {i.get('title')}\n")
+                sys.stdout.write(f"{'-' * 75}\n")
+            else:
                 for i in collection_list:
-                    gs.message(_(f"{i.get('id')}: {i.get('title')}"))
-                    gs.message(_(f"{i.get('description')}"))
-                    gs.message(_(f"Extent: {i.get('extent')}"))
-                    gs.message(_(f"License: {i.get('license')}"))
-                    gs.message(_(f"{'-' * 75}\n"))
+                    sys.stdout.write(f"Collection: {i.get('title')}\n")
+                    sys.stdout.write(f"{'-' * 75}\n")
+                    sys.stdout.write(f"Collection Id: {i.get('id')}\n")
+                    sys.stdout.write(f"{i.get('description')}\n")
+                    sys.stdout.write(f"Extent: {i.get('extent')}\n")
+                    sys.stdout.write(f"License: {i.get('license')}\n")
+                    sys.stdout.write(f"{'-' * 75}\n")
                     libstac.print_list_attribute(
                         client.get_conforms_to(), "Conforms To:"
                     )
-                    gs.message(_(f"{'-' * 75}\n"))
+                    sys.stdout.write(f"{'-' * 75}\n")
                 return None
         else:
-            json_output = json.dumps(client.to_dict())
-            return json_output
+            client_dict = client.to_dict()
+            libstac.print_json_to_stdout(client_dict, pretty_print)
 
-    except APIError as e:
-        gs.fatal(_("APIError Error opening STAC API: {}".format(e)))
+    except Exception as e:
+        gs.fatal(_("Error: {}".format(e)))
 
 
 if __name__ == "__main__":
diff --git a/src/temporal/t.stac/t.stac.collection/t.stac.collection.py b/src/temporal/t.stac/t.stac.collection/t.stac.collection.py
index 70a38d8268..74000224f0 100644
--- a/src/temporal/t.stac/t.stac.collection/t.stac.collection.py
+++ b/src/temporal/t.stac/t.stac.collection/t.stac.collection.py
@@ -71,36 +71,43 @@
 # % description: Return basic information only
 # %end
 
+# %flag
+# % key: p
+# % description: Pretty print the JSON output
+# %end
+
 import sys
+import json
+from io import StringIO
 from pprint import pprint
+from contextlib import contextmanager
 import grass.script as gs
 from grass.pygrass.utils import get_lib_path
 
 
-from pystac_client import Client
-from pystac_client.exceptions import APIError
-from pystac_client.conformance import ConformanceClasses
-
-path = get_lib_path(modname="t.stac", libname="staclib")
-if path is None:
-    gs.fatal("Not able to find the stac library directory.")
-sys.path.append(path)
-
-
-def get_all_collections(client):
-    """Get a list of collections from STAC Client"""
+@contextmanager
+def add_sys_path(new_path):
+    """Add a path to sys.path and remove it when done"""
+    original_sys_path = sys.path[:]
+    sys.path.append(new_path)
     try:
-        collections = client.get_collections()
-        collection_list = list(collections)
-        return [i.to_dict() for i in collection_list]
-
-    except APIError as e:
-        gs.fatal(_("Error getting collections: {}".format(e)))
+        yield
+    finally:
+        sys.path = original_sys_path
 
 
 def main():
     """Main function"""
-    import staclib as libstac
+    # Import dependencies
+    path = get_lib_path(modname="t.stac", libname="staclib")
+    if path is None:
+        gs.fatal("Not able to find the stac library directory.")
+
+    with add_sys_path(path):
+        try:
+            import staclib as libstac
+        except ImportError as err:
+            gs.fatal(f"Unable to import staclib: {err}")
 
     # STAC Client options
     client_url = options["url"]  # required
@@ -112,41 +119,26 @@ def main():
 
     # Flag options
     basic_info = flags["b"]  # optional
+    pretty_print = flags["p"]  # optional
 
     # Set the request headers
     settings = options["settings"]
     req_headers = libstac.set_request_headers(settings)
 
-    try:
-        client = Client.open(client_url, headers=req_headers)
-    except APIError as e:
-        gs.fatal(_("APIError Error opening STAC API: {}".format(e)))
-
-    if libstac.conform_to_collections(client):
-        gs.verbose(_("Conforms to STAC Collections"))
+    # Connect to STAC API
+    stac_helper = libstac.STACHelper()
+    stac_helper.connect_to_stac(client_url, req_headers)
+    stac_helper.conforms_to_collections()
 
     if collection_id:
-        try:
-            collection = client.get_collection(collection_id)
-            collection_dict = collection.to_dict()
-            if format == "json":
-                gs.message(_(f"collection: {collection}"))
-                return collection_dict
-                # return pprint(collection.to_dict())
-            elif format == "plain":
-                if basic_info:
-                    return libstac.print_basic_collection_info(collection_dict)
-                return libstac.print_summary(collection_dict)
-
-        except APIError as e:
-            gs.fatal(_("APIError Error getting collection: {}".format(e)))
-
-    # Create metadata vector
-    # if vector_metadata:
-    #     gs.message(_(f"Outputting metadata to {vector_metadata}"))
-    #     libstac.create_metadata_vector(vector_metadata, collection_list)
-    #     gs.message(_(f"Metadata written to {vector_metadata}"))
-    #     return vector_metadata
+        collection_dict = stac_helper.get_collection(collection_id)
+
+        if format == "plain":
+            if basic_info:
+                return libstac.print_basic_collection_info(collection_dict)
+            return libstac.print_summary(collection_dict)
+        elif format == "json":
+            return libstac.print_json_to_stdout(collection_dict, pretty_print)
 
 
 if __name__ == "__main__":
diff --git a/src/temporal/t.stac/t.stac.item/t.stac.item.py b/src/temporal/t.stac/t.stac.item/t.stac.item.py
index 00fbdbc19a..aa06300f58 100644
--- a/src/temporal/t.stac/t.stac.item/t.stac.item.py
+++ b/src/temporal/t.stac/t.stac.item/t.stac.item.py
@@ -251,6 +251,11 @@
 # % description: Dowload and import assets
 # %end
 
+# %flag
+# % key: p
+# % description: Pretty print the JSON output
+# %end
+
 # %option G_OPT_M_NPROCS
 # %end
 
@@ -261,102 +266,21 @@
 import sys
 from pprint import pprint
 import json
-
-# from multiprocessing.pool import ThreadPool
-from pystac_client import Client
-from pystac_client.exceptions import APIError
-from pystac import MediaType
-from concurrent.futures import ThreadPoolExecutor
-from tqdm import tqdm
-import tempfile
-
+from io import StringIO
+from contextlib import contextmanager
 import grass.script as gs
 from grass.pygrass.utils import get_lib_path
-from grass.exceptions import CalledModuleError
-
-
-path = get_lib_path(modname="t.stac", libname="staclib")
-if path is None:
-    gs.fatal("Not able to find the stac library directory.")
-sys.path.append(path)
 
-import staclib as libstac
-
-
-def search_stac_api(client, **kwargs):
-    """Search the STAC API"""
-    if libstac.conform_to_item_search(client):
-        gs.verbose(_("STAC API Conforms to Item Search"))
-    try:
-        search = client.search(**kwargs)
-    except APIError as e:
-        gs.fatal(_("Error searching STAC API: {}".format(e)))
-    except NotImplementedError as e:
-        gs.fatal(_("Error searching STAC API: {}".format(e)))
-    except Exception as e:
-        gs.fatal(_("Error searching STAC API: {}".format(e)))
 
+@contextmanager
+def add_sys_path(new_path):
+    """Add a path to sys.path and remove it when done"""
+    original_sys_path = sys.path[:]
+    sys.path.append(new_path)
     try:
-        gs.message(_(f"Search Matched: {search.matched()} items"))
-        # These requests tend to be very slow
-        # gs.message(_(f"Pages: {len(list(search.pages()))}"))
-        # gs.message(_(f"Max items per page: {len(list(search.items()))}"))
-
-    except e:
-        gs.warning(_(f"No items found: {e}"))
-        return None
-
-    return search
-
-
-def collection_metadata(collection):
-    """Get collection"""
-
-    gs.message(_("*" * 80))
-    gs.message(_(f"Collection Id: {collection.id}"))
-
-    libstac.print_attribute(collection, "title", "Collection Title")
-    libstac.print_attribute(collection, "description", "Description")
-    gs.message(_(f"Spatial Extent: {collection.extent.spatial.bboxes}"))
-    gs.message(_(f"Temporal Extent: {collection.extent.temporal.intervals}"))
-
-    libstac.print_attribute(collection, "license")
-    libstac.print_attribute(collection, "keywords")
-    libstac.print_attribute(collection, "links")
-    libstac.print_attribute(collection, "providers")
-    libstac.print_attribute(collection, "stac_extensions", "Extensions")
-
-    try:
-        gs.message(_("\n# Summaries:"))
-        libstac.print_summary(collection.summaries.to_dict())
-    except AttributeError:
-        gs.info(_("Summaries not found."))
-
-    try:
-        gs.message(_("\n# Extra Fields:"))
-        libstac.print_summary(collection.extra_fields)
-    except AttributeError:
-        gs.info(_("# Extra Fields not found."))
-    gs.message(_("*" * 80))
-
-
-def report_stac_item(item):
-    """Print a report of the STAC item to the console."""
-    gs.message(_(f"Collection ID: {item.collection_id}"))
-    gs.message(_(f"Item: {item.id}"))
-    libstac.print_attribute(item, "geometry", "Geometry")
-    gs.message(_(f"Bbox: {item.bbox}"))
-
-    libstac.print_attribute(item, "datetime", "Datetime")
-    libstac.print_attribute(item, "start_datetime", "Start Datetime")
-    libstac.print_attribute(item, "end_datetime", "End Datetime")
-    gs.message(_("Extra Fields:"))
-    libstac.print_summary(item.extra_fields)
-
-    libstac.print_list_attribute(item.stac_extensions, "Extensions:")
-    # libstac.print_attribute(item, "stac_extensions", "Extensions")
-    gs.message(_("Properties:"))
-    libstac.print_summary(item.properties)
+        yield
+    finally:
+        sys.path = original_sys_path
 
 
 def collect_item_assets(item, assset_keys, asset_roles):
@@ -381,90 +305,18 @@ def collect_item_assets(item, assset_keys, asset_roles):
         return asset_dict
 
 
-def report_plain_asset_summary(asset):
-    gs.message(_("\nAsset"))
-    gs.message(_(f"Asset Item Id: {asset.get('item_id')}"))
-
-    gs.message(_(f"Asset Title: {asset.get('title')}"))
-    gs.message(_(f"Asset Filename: {asset.get('file_name')}"))
-    gs.message(_(f"Raster bands: {asset.get('raster:bands')}"))
-    gs.message(_(f"Raster bands: {asset.get('eo:bands')}"))
-    gs.message(_(f"Asset Description: {asset.get('description')}"))
-    gs.message(_(f"Asset Media Type: { MediaType(asset.get('type')).name}"))
-    gs.message(_(f"Asset Roles: {asset.get('roles')}"))
-    gs.message(_(f"Asset Href: {asset.get('href')}"))
-
-
-def import_grass_raster(params):
-    assets, resample_method, extent, resolution, resolution_value, memory = params
-    gs.message(_(f"Downloading Asset: {assets}"))
-    input_url = libstac.check_url_type(assets["href"])
-    gs.message(_(f"Import Url: {input_url}"))
-
-    try:
-        gs.message(_(f"Importing: {assets['file_name']}"))
-        gs.parse_command(
-            "r.import",
-            input=input_url,
-            output=assets["file_name"],
-            resample=resample_method,
-            extent=extent,
-            resolution=resolution,
-            resolution_value=resolution_value,
-            title=assets["file_name"],
-            memory=memory,
-            quiet=True,
-        )
-    except CalledModuleError as e:
-        gs.fatal(_("Error importing raster: {}".format(e.stderr)))
-
-
-def download_assets(
-    assets,
-    resample_method,
-    resample_extent,
-    resolution,
-    resolution_value,
-    memory=300,
-    nprocs=1,
-):
-    """Downloads a list of images from the given URLs to the given filenames."""
-    number_of_assets = len(assets)
-    resample_extent_list = [resample_extent] * number_of_assets
-    resolution_list = [resolution] * number_of_assets
-    resolution_value_list = [resolution_value] * number_of_assets
-    resample_method_list = [resample_method] * number_of_assets
-    memory_list = [memory] * number_of_assets
-    max_cpus = os.cpu_count() - 1
-    if nprocs > max_cpus:
-        gs.warning(
-            _(
-                "Number of processes {nprocs} is greater than the number of CPUs {max_cpus}."
-            )
-        )
-        nprocs = max_cpus
-
-    with tqdm(total=number_of_assets, desc="Downloading assets") as pbar:
-        with ThreadPoolExecutor(max_workers=nprocs) as executor:
-            try:
-                for _a in executor.map(
-                    import_grass_raster,
-                    zip(
-                        assets,
-                        resample_method_list,
-                        resample_extent_list,
-                        resolution_list,
-                        resolution_value_list,
-                        memory_list,
-                    ),
-                ):
-                    pbar.update(1)
-            except Exception as e:
-                gs.fatal(_("Error importing raster: {}".format(str(e))))
-
-
 def main():
     """Main function"""
+    # Import dependencies
+    path = get_lib_path(modname="t.stac", libname="staclib")
+    if path is None:
+        gs.fatal("Not able to find the stac library directory.")
+
+    with add_sys_path(path):
+        try:
+            import staclib as libstac
+        except ImportError as err:
+            gs.fatal(f"Unable to import staclib: {err}")
 
     # STAC Client options
     client_url = options["url"]  # required
@@ -494,6 +346,7 @@ def main():
     item_metadata = flags["i"]
     asset_metadata = flags["a"]
     download = flags["d"]
+    pretty_print = flags["p"]  # optional
 
     # Output options
     strds_output = options["strds_output"]  # optional
@@ -512,32 +365,22 @@ def main():
     search_params = {}  # Store STAC API search parameters
     collection_items_assets = []
 
-    try:
-
-        # Set the request headers
-        settings = options["settings"]
-        req_headers = libstac.set_request_headers(settings)
-
-        client = Client.open(client_url, headers=req_headers)
-    except APIError as e:
-        gs.fatal(_("APIError Error opening STAC API: {}".format(e)))
+    # Set the request headers
+    settings = options["settings"]
+    req_headers = libstac.set_request_headers(settings)
 
-    try:
-        collection = client.get_collection(collection_id)
-    except APIError as e:
-        gs.fatal(_(f"Error getting collection {collection_id}: {e}"))
+    # Connect to STAC API
+    stac_helper = libstac.STACHelper()
+    stac_helper.connect_to_stac(client_url, req_headers)
+    collection = stac_helper.get_collection(collection_id)
 
     if summary_metadata:
         if format == "plain":
-            return collection_metadata(collection)
+            return libstac.collection_metadata(collection)
         elif format == "json":
-            return pprint(collection.to_dict())
-        else:
-            # Return plain text by default
-            return collection_metadata(collection)
+            return libstac.print_json_to_stdout(collection, pretty_print)
 
     # Start item search
-
     if intersects:
         # Convert the vector to a geojson
         output_geojson = "tmp_stac_intersects.geojson"
@@ -556,7 +399,7 @@ def main():
 
     # Set the bbox to the current region if the user did not specify the bbox or intersects option
     if not bbox and not intersects:
-        gs.message(_("Setting bbox to current region: {}".format(bbox)))
+        gs.verbose(_("Setting bbox to current region: {}".format(bbox)))
         bbox = libstac.region_to_wgs84_decimal_degrees_bbox()
 
     if datetime:
@@ -573,8 +416,6 @@ def main():
     if filter_lang:
         search_params["filter_lang"] = filter_lang
 
-    if libstac.conform_to_query(client):
-        gs.verbose(_("STAC API Conforms to Item Search Query"))
     if query:
         if isinstance(query, str):
             query = json.loads(query)
@@ -591,7 +432,8 @@ def main():
     search_params["bbox"] = bbox
 
     # Search the STAC API
-    items_search = search_stac_api(client=client, **search_params)
+    items_search = stac_helper.search_api(**search_params)
+
     # Create vector layer of items metadata
     if items_vector:
         libstac.create_vector_from_feature_collection(
@@ -604,12 +446,14 @@ def main():
     # Report item metadata
     if item_metadata:
         if format == "plain":
+            gs.message(_("bbox: {}\n".format(bbox)))
             gs.message(_(f"Items Found: {len(list(items))}"))
             for item in items:
-                report_stac_item(item)
+                stac_helper.report_stac_item(item)
             return None
         if format == "json":
-            return pprint([item.to_dict() for item in items])
+            item_list = [item.to_dict() for item in items]
+            return libstac.print_json_to_stdout(item_list, pretty_print)
 
     for item in items:
         asset = collect_item_assets(item, asset_keys, asset_roles=item_roles)
@@ -620,17 +464,20 @@ def main():
         strds_output = os.path.abspath(strds_output)
         libstac.register_strds_from_items(collection_items_assets, strds_output)
 
-    gs.message(_(f"{len(collection_items_assets)} Assets Ready for download..."))
     if asset_metadata:
-        for asset in collection_items_assets:
-            if format == "plain":
-                report_plain_asset_summary(asset)
-            if format == "json":
-                pprint(asset)
+        if format == "plain":
+            gs.message(
+                _(f"{len(collection_items_assets)} Assets Ready for download...")
+            )
+            for asset in collection_items_assets:
+                libstac.report_plain_asset_summary(asset)
+
+        if format == "json":
+            return libstac.print_json_to_stdout(collection_items_assets, pretty_print)
 
     if download:
         # Download and Import assets
-        download_assets(
+        libstac.download_assets(
             assets=collection_items_assets,
             resample_method=method,
             resample_extent=extent,