from functools import total_ordering
from pystac import STACObjectType
from pystac.version import STACVersion
from pystac.extensions import Extensions
[docs]class STACJSONDescription:
"""Describes the STAC object information for a STAC object represented in JSON
Attributes:
object_type (str): Describes the STAC object type. One of :class:`~pystac.STACObjectType`.
version_range (STACVersionRange): The STAC version range that describes what
has been identified as potential valid versions of the stac object.
common_extensions (List[str]): List of common extension IDs implemented by this
STAC object.
custom_extensions (List[str]): List of custom extensions (URIs to JSON Schemas)
used by this STAC Object.
"""
def __init__(self, object_type, version_range, common_extensions, custom_extensions):
self.object_type = object_type
self.version_range = version_range
self.common_extensions = common_extensions
self.custom_extensions = custom_extensions
def __repr__(self):
return '<{} {} common_ext={} custom_ext={}>'.format(self.object_type, self.version_range,
','.join(self.common_extensions),
','.join(self.custom_extensions))
[docs]@total_ordering
class STACVersionID:
"""Defines STAC versions in an object that is orderable based on version number.
For instance, ``1.0.0-beta.2 < 1.0.0``
"""
def __init__(self, version_string):
self.version_string = version_string
# Account for RC or beta releases in version
version_parts = version_string.split('-')
self.version_core = version_parts[0]
if len(version_parts) == 1:
self.version_prerelease = None
else:
self.version_prerelease = '-'.join(version_parts[1:])
def __str__(self):
return self.version_string
def __eq__(self, other):
if type(other) is str:
other = STACVersionID(other)
return self.version_string == other.version_string
def __ne__(self, other):
return not self.__eq__(other)
def __lt__(self, other):
if type(other) is str:
other = STACVersionID(other)
if self.version_core < other.version_core:
return True
elif self.version_core > other.version_core:
return False
else:
return self.version_prerelease is not None and (
other.version_prerelease is None
or other.version_prerelease > self.version_prerelease)
[docs]class STACVersionRange:
"""Defines a range of STAC versions."""
def __init__(self, min_version='0.4.0', max_version=None):
if type(min_version) is str:
self.min_version = STACVersionID(min_version)
else:
self.min_version = min_version
if max_version is None:
self.max_version = STACVersionID(STACVersion.DEFAULT_STAC_VERSION)
else:
if type(max_version) is str:
self.max_version = STACVersionID(max_version)
else:
self.max_version = max_version
[docs] def set_min(self, v):
if self.min_version < v:
if v < self.max_version:
self.min_version = v
else:
self.min_version = self.max_version
[docs] def set_max(self, v):
if v < self.max_version:
if self.min_version < v:
self.max_version = v
else:
self.max_version = self.min_version
[docs] def set_to_single(self, v):
self.set_min(v)
self.set_max(v)
[docs] def latest_valid_version(self):
return self.max_version
[docs] def contains(self, v):
if type(v) is str:
v = STACVersionID(v)
return self.min_version <= v and v <= self.max_version
[docs] def is_single_version(self):
return self.min_version >= self.max_version
[docs] def is_earlier_than(self, v):
if type(v) is str:
v = STACVersionID(v)
return self.max_version < v
[docs] def is_later_than(self, v):
if type(v) is str:
v = STACVersionID(v)
return v < self.min_version
def __repr__(self):
return '<VERSIONS {}-{}>'.format(self.min_version, self.max_version)
def _identify_stac_extensions(object_type, d, version_range):
"""Identifies extensions for STAC Objects that don't list their
extensions in a 'stac_extensions' property.
Returns a list of stac_extensions. May mutate the version_range to update
min or max version.
"""
stac_extensions = set([])
# assets (collection assets)
if object_type == STACObjectType.ITEMCOLLECTION:
if 'assets' in d:
stac_extensions.add('assets')
version_range.set_min('0.8.0')
# checksum
if 'links' in d:
found_checksum = False
for link in d['links']:
if any(filter(lambda p: p.startswith('checksum:'), link)):
found_checksum = True
stac_extensions.add(Extensions.CHECKSUM)
if not found_checksum:
if 'assets' in d:
for asset in d['assets']:
if any(filter(lambda p: p.startswith('checksum:'), link)):
found_checksum = True
stac_extensions.add(Extensions.CHECKSUM)
if found_checksum:
version_range.set_min('0.6.2')
# datacube
if object_type == STACObjectType.ITEM:
if any(filter(lambda k: k.startswith('cube:'), d['properties'])):
stac_extensions.add(Extensions.DATACUBE)
version_range.set_min('0.6.1')
# datetime-range (old extension)
if object_type == STACObjectType.ITEM:
if 'dtr:start_datetime' in d['properties']:
stac_extensions.add('datetime-range')
version_range.set_min('0.6.0')
# eo
if object_type == STACObjectType.ITEM:
if any(filter(lambda k: k.startswith('eo:'), d['properties'])):
stac_extensions.add(Extensions.EO)
if 'eo:epsg' in d['properties']:
if d['properties']['eo:epsg'] is None:
version_range.set_min('0.6.1')
if 'eo:crs' in d['properties']:
version_range.set_max('0.4.1')
if 'eo:constellation' in d['properties']:
version_range.set_min('0.6.0')
if 'eo:bands' in d:
stac_extensions.add(Extensions.EO)
version_range.set_max('0.5.2')
# pointcloud
if object_type == STACObjectType.ITEM:
if any(filter(lambda k: k.startswith('pc:'), d['properties'])):
stac_extensions.add(Extensions.POINTCLOUD)
version_range.set_min('0.6.2')
# sar
if object_type == STACObjectType.ITEM:
if any(filter(lambda k: k.startswith('sar:'), d['properties'])):
stac_extensions.add(Extensions.SAR)
version_range.set_min('0.6.2')
if version_range.contains('0.6.2'):
for prop in [
'sar:absolute_orbit', 'sar:resolution', 'sar:pixel_spacing', 'sar:looks'
]:
if prop in d['properties']:
if isinstance(d['properties'][prop], list):
version_range.set_max('0.6.2')
if version_range.contains('0.7.0'):
for prop in [
'sar:incidence_angle', 'sar:relative_orbit', 'sar:observation_direction',
'sar:resolution_range', 'sar:resolution_azimuth', 'sar:pixel_spacing_range',
'sar:pixel_spacing_azimuth', 'sar:looks_range', 'sar:looks_azimuth',
'sar:looks_equivalent_number'
]:
if prop in d['properties']:
version_range.set_min('0.7.0')
if 'sar:absolute_orbit' in d['properties'] and not isinstance(
d['properties']['sar:absolute_orbit'], list):
version_range.set_min('0.7.0')
if 'sar:off_nadir' in d['properties']:
version_range.set_max('0.6.2')
# scientific
if object_type == STACObjectType.ITEM or object_type == STACObjectType.COLLECTION:
if 'properties' in d:
if any(filter(lambda k: k.startswith('sci:'), d['properties'])):
stac_extensions.add(Extensions.SCIENTIFIC)
version_range.set_min('0.6.0')
# Single File STAC
if object_type == STACObjectType.ITEMCOLLECTION:
if 'collections' in d:
stac_extensions.add(Extensions.SINGLE_FILE_STAC)
version_range.set_min('0.8.0')
if 'stac_extensions' not in d:
version_range.set_max('0.8.1')
return list(stac_extensions)
def _split_extensions(stac_extensions):
"""Split extensions into common_extensions and custom_extensions"""
common_extensions = []
custom_extensions = []
for ext in stac_extensions:
# Custom extensions are a URI
if ext.endswith('.json') or '/' in ext:
custom_extensions.append(ext)
else:
common_extensions.append(ext)
return (common_extensions, custom_extensions)
def identify_stac_object_type(json_dict):
"""Determines the STACObjectType of the provided JSON dict.
Args:
json_dict (dict): The dict of STAC JSON to identify.
Returns:
STACObjectType: The object type represented by the JSON.
"""
object_type = None
# Identify pre-1.0 ITEMCOLLECTION (since removed)
if 'type' in json_dict and 'assets' not in json_dict:
if 'stac_version' in json_dict and json_dict['stac_version'].startswith('0'):
if json_dict['type'] == 'FeatureCollection':
object_type = STACObjectType.ITEMCOLLECTION
if 'extent' in json_dict:
object_type = STACObjectType.COLLECTION
elif 'assets' in json_dict:
object_type = STACObjectType.ITEM
else:
object_type = STACObjectType.CATALOG
return object_type
[docs]def identify_stac_object(json_dict):
"""Determines the STACJSONDescription of the provided JSON dict.
Args:
json_dict (dict): The dict of STAC JSON to identify.
Returns:
STACJSONDescription: The description of the STAC object serialized in the
given dict.
"""
object_type = identify_stac_object_type(json_dict)
version_range = STACVersionRange()
stac_version = json_dict.get('stac_version')
stac_extensions = json_dict.get('stac_extensions', None)
if stac_version is None:
if object_type == STACObjectType.CATALOG or object_type == STACObjectType.COLLECTION:
version_range.set_max('0.5.2')
elif object_type == STACObjectType.ITEM:
version_range.set_max('0.7.0')
else: # ItemCollection
version_range.set_min('0.8.0')
else:
version_range.set_to_single(stac_version)
if stac_extensions is not None:
version_range.set_min('0.8.0')
if stac_extensions is None:
# If this is post-0.8, we can assume there are no extensions
# if the stac_extensions property doesn't exist for everything
# but ItemCollection (except after 0.9.0, when ItemCollection also got
# the stac_extensions property).
if version_range.is_earlier_than('0.8.0') or \
(object_type == STACObjectType.ITEMCOLLECTION and not version_range.is_later_than(
'0.8.1')):
stac_extensions = _identify_stac_extensions(object_type, json_dict, version_range)
else:
stac_extensions = []
if not version_range.is_single_version():
# Final Checks
if 'links' in json_dict:
# links were a dictionary only in 0.5
if 'links' in json_dict and isinstance(json_dict['links'], dict):
version_range.set_to_single('0.5.2')
# self links became non-required in 0.7.0
if not version_range.is_earlier_than('0.7.0') and \
not any(filter(lambda l: l['rel'] == 'self',
json_dict['links'])):
version_range.set_min('0.7.0')
common_extensions, custom_extensions = _split_extensions(stac_extensions)
return STACJSONDescription(object_type, version_range, common_extensions, custom_extensions)