diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..e5bd6c576 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.pyc +*.egg-info +build/ +dist/ +*.eggs +MANIFEST +.DS_Store +.coverage +.cache +data +config.json +stdout* +/integration* +.idea/ +docs/_build/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..218fd6532 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [v0.1.0] - 2019-01-13 + +Initial Release diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..cf96bb33b --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +This software is licensed under the Apache 2 license, quoted below. + +Copyright 2017 Azavea [http://www.azavea.com] + +Licensed under the Apache License, Version 2.0 (the "License"); you may not +use this file except in compliance with the License. You may obtain a copy of +the License at + + [http://www.apache.org/licenses/LICENSE-2.0] + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations under +the License. diff --git a/README.md b/README.md new file mode 100644 index 000000000..fe9bf7f08 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +## PySTAC + +TKTK diff --git a/pystac/__init__.py b/pystac/__init__.py new file mode 100644 index 000000000..6deaf7198 --- /dev/null +++ b/pystac/__init__.py @@ -0,0 +1,26 @@ +from pystac.version import (__version__, STAC_VERSION) + +class STACError(Exception): + pass + +from pystac.io import STAC_IO +from pystac.link import Link +from pystac.catalog import Catalog +from pystac.collection import (Collection, Extent, SpatialExtent, TemporalExtent, Provider) +from pystac.item import (Item, Asset) +from pystac.eo import * +from pystac.label import * + +def stac_object_from_dict(d): + """Determines how to deserialize a dictionary into a STAC object.""" + if 'type' in d: + if 'label:description' in d['properties']: + return LabelItem.from_dict(d) + else: + return Item.from_dict(d) + elif 'extent' in d: + return Collection.from_dict(d) + else: + return Catalog.from_dict(d) + +STAC_IO.stac_object_from_dict = stac_object_from_dict diff --git a/pystac/catalog.py b/pystac/catalog.py new file mode 100644 index 000000000..36d36a5ca --- /dev/null +++ b/pystac/catalog.py @@ -0,0 +1,235 @@ +import os +import json + +from pystac import STAC_VERSION +from pystac.stac_object import STACObject +from pystac.io import STAC_IO +from pystac.link import Link +from pystac.resolved_object_cache import ResolvedObjectCache + +class Catalog(STACObject): + DEFAULT_FILE_NAME = "catalog.json" + + def __init__(self, id, description, title=None, href=None): + self.id = id + self.description = description + self.title = title + self.links = [Link.root(self)] + + if href is not None: + self.set_self_href(href) + + self._resolved_objects = ResolvedObjectCache() + + def __repr__(self): + return ''.format(self.id) + + def set_root(self, root, recursive=False): + STACObject.set_root(self, root) + root._resolved_objects = ResolvedObjectCache.merge(root._resolved_objects, + self._resolved_objects) + + if recursive: + for child in self.get_children(): + child.set_root(root, recurive=True) + for item in self.get_items(): + item.set_root(root) + + def add_child(self, child, title=None): + child.set_root(self.get_root()) + child.set_parent(self) + self.add_link(Link.child(child, title=title)) + + def add_item(self, item, title=None): + item.set_root(self.get_root()) + item.set_parent(self) + self.add_link(Link.item(item, title=title)) + + def add_items(self, items): + for item in items: + self.add_item(item) + + def get_child(self, id): + return next((c for c in self.get_children() if c.id == id), None) + + def get_children(self): + return self.get_stac_objects('child', parent=self) + + def get_child_links(self): + return self.get_links('child') + + def clear_children(self): + self.links = [l for l in self.links if l.rel != 'child'] + return self + + def get_item(self, id): + return next((i for i in self.get_items() if i.id == id), None) + + def get_items(self): + return self.get_stac_objects('item', parent=self) + + def clear_items(self): + self.links = [l for l in self.links if l.rel != 'item'] + return self + + def get_all_items(self): + """Get all items from this catalog and all subcatalogs.""" + items = self.get_items() + for child in self.get_children(): + items += child.get_all_items() + + return items + + def get_item_links(self): + return self.get_links('item') + + def to_dict(self): + d = { + 'id': self.id, + 'stac_version': STAC_VERSION, + 'description': self.description, + 'links': [l.to_dict() for l in self.links] + } + + if self.title is not None: + d['title'] = self.title + + return d + + def clone(self): + clone = Catalog(id=self.id, + description=self.description, + title=self.title) + clone._resolved_objects.set(clone) + + clone.add_links([l.clone() for l in self.links]) + + return clone + + def set_uris_from_root(self, root_uri): + self.set_self_href(os.path.join(root_uri, self.DEFAULT_FILE_NAME)) + for child in self.get_children(): + child_root = os.path.join(root_uri, '{}/'.format(child.id)) + child.set_uris_from_root(child_root) + for item in self.get_items(): + item.set_self_href(os.path.join(root_uri, '{}.json'.format(item.id))) + + def set_relative_paths(self, include_assets=True): + """Converts all HREFs in links (and optionally assets) into relative paths. + + Any path that does not share a root with the self HREF (i.e. cannot be made relative) + will be skipped. + """ + self_href = self.get_self_href() + if self_href is None: + raise STACError('Self HREFs must be set in order to make relative paths.') + + os.path.basename(self_href) + + def save(self): + for child_link in self.get_child_links(): + if child_link.is_resolved(): + child_link.target.save() + + for item_link in self.get_item_links(): + if item_link.is_resolved(): + item_link.target.save() + + STAC_IO.save_json(self.get_self_href(), self.to_dict()) + + def map_items(self, item_mapper): + """Creates a copy of a catalog, with each item passed through the item_mapper function. + + Args: + item_mapper: A function that takes in an item, and returns either an item or list of items. + The item that is passed into the item_mapper is a copy, so the method can mutate it safetly. + """ + + new_cat = self.full_copy() + + def process_catalog(catalog): + for child in catalog.get_children(): + process_catalog(child) + + item_links = [] + for item_link in catalog.get_item_links(): + mapped = item_mapper(item_link.target) + if type(mapped) is not list: + item_link.target = mapped + item_links.append(item_link) + else: + for i in mapped: + l = item_link.clone() + l.target = i + item_links.append(l) + catalog.clear_items() + catalog.add_links(item_links) + + process_catalog(new_cat) + return new_cat + + def map_assets(self, asset_mapper): + """Creates a copy of a catalog, with each Asset for each Item passed + through the asset_mapper function. + + Args: + asset_mapper: A function that takes in an key and an Asset, and returns + either an Asset, a (key, Asset), or a dictionary of Assets with unique keys. + The Asset that is passed into the item_mapper is a copy, so the method can mutate it safetly. + """ + def apply_asset_mapper(tup): + k, v = tup + result = asset_mapper(k, v) + if issubclass(type(result), Asset): + return [(k, result)] + elif isinstance(result, tuple): + return [result] + else: + assets = list(result.items()) + if len(assets) < 1: + raise Exception('asset_mapper must return a non-empy list') + return assets + + def item_mapper(item): + new_assets = [x for result in map(apply_asset_mapper, item.assets.items()) + for x in result] + item.assets = dict(new_assets) + return item + + return self.map_items(item_mapper) + + def describe(self, indent=0, include_hrefs=False): + s = '{}* {}'.format(' ' * indent, self) + if include_hrefs: + s += ' {}'.format(self.get_self_href()) + print(s) + for child in self.get_children(): + child.describe(indent=indent+4) + for item in self.get_items(): + s = '{}* {}'.format(' ' * (indent+2), item) + if include_hrefs: + s += ' {}'.format(item.get_self_href()) + print(s) + + @staticmethod + def from_dict(d): + id = d['id'] + description = d['description'] + title = d.get('title') + + cat = Catalog(id=id, + description=description, + title=title) + + for l in d['links']: + if not l['rel'] == 'root': + cat.add_link(Link.from_dict(l)) + + return cat + + @staticmethod + def from_file(uri): + d = json.loads(STAC_IO.read_text(uri)) + c = Catalog.from_dict(d) + c.set_self_href(uri) + return c diff --git a/pystac/collection.py b/pystac/collection.py new file mode 100644 index 000000000..c88a7cfb1 --- /dev/null +++ b/pystac/collection.py @@ -0,0 +1,253 @@ +from datetime import datetime +from datetime import timezone +import dateutil.parser +from copy import copy + +from pystac import STACError +from pystac.catalog import Catalog +from pystac.link import Link + +class Collection(Catalog): + DEFAULT_FILE_NAME = "collection.json" + + def __init__(self, + id, + description, + extent, + title=None, + href=None, + license='proprietary', + stac_extensions=None, + keywords=None, + version=None, + providers=None, + properties=None, + summaries=None): + super(Collection, self).__init__(id, description, title, href) + self.extent = extent + self.license = license + + self.stac_extensions = stac_extensions + self.keywords = keywords + self.version = version + self.providers = providers + self.properties = properties + self.summaries = summaries + + def __repr__(self): + return ''.format(self.id) + + def add_item(self, item, title=None): + super(Collection, self).add_item(item, title) + item.set_collection(self) + + def to_dict(self): + d = super(Collection, self).to_dict() + d['extent'] = self.extent.to_dict() + d['license'] = self.license + if self.stac_extensions is not None: + d['stac_extensions'] = self.stac_extensions + if self.keywords is not None: + d['keywords'] = self.keywords + if self.version is not None: + d['version'] = self.version + if self.providers is not None: + d['providers'] = list(map(lambda x: x.to_dict(), self.providers)) + if self.properties is not None: + d['properties'] = self.properties + if self.summaries is not None: + d['summaries'] = self.summaries + + return d + + def clone(self): + clone = Collection(id=self.id, + description=self.description, + extent=self.extent.clone(), + title=self.title, + license=self.license, + stac_extensions = self.stac_extensions, + keywords = self.keywords, + version=self.version, + providers=self.providers, + properties=self.properties, + summaries=self.summaries) + + clone._resolved_objects.set(clone) + + clone.add_links([l.clone() for l in self.links]) + + return clone + + @staticmethod + def from_dict(d): + id = d['id'] + description = d['description'] + license = d['license'] + extent = Extent.from_dict(d['extent']) + title = d.get('title') + stac_extensions = d.get('stac_extensions') + keywords = d.get('keywords') + version = d.get('version') + providers = d.get('providers') + if providers is not None: + providers = list(map(lambda x: Provider.from_dict(x), providers)) + properties = d.get('properties') + summaries = d.get('summaries') + + collection = Collection(id=id, + description=description, + extent=extent, + title=title, + license=license, + stac_extensions=stac_extensions, + keywords=keywords, + version=version, + providers=providers, + properties=properties, + summaries=summaries) + + for l in d['links']: + collection.add_link(Link.from_dict(l)) + + return collection + + @staticmethod + def from_file(uri): + d = json.loads(STAC_IO.read_text(uri)) + c = Collection.from_dict(d) + c.set_self_href(uri) + return c + + +class Extent: + def __init__(self, spatial, temporal): + self.spatial = spatial + self.temporal = temporal + + def to_dict(self): + return { + 'spatial': self.spatial.to_dict(), + 'temporal': self.temporal.to_dict() + } + + def clone(self): + return Extent(spatial=copy(self.spatial), + temporal=copy(self.temporal)) + + @staticmethod + def from_dict(d): + return Extent(SpatialExtent.from_dict(d['spatial']), + TemporalExtent.from_dict(d['temporal'])) + + +class SpatialExtent: + def __init__(self, bboxes): + self.bboxes = bboxes + + def to_dict(self): + return { 'bbox' : self.bboxes } + + def clone(self): + return SpatialExtent(self.bboxes) + + @staticmethod + def from_dict(d): + return SpatialExtent(bboxes=d['bbox']) + + @staticmethod + def from_coordinates(coordinates): + def process_coords(l, xmin=None, ymin=None, xmax=None, ymax=None): + for coord in l: + if type(coord[0]) is list: + xmin, ymin, xmax, ymax = process_coords(coord, xmin, ymin, xmax, ymax) + else: + x, y = coord + if xmin is None or x < xmin: + xmin = x + elif xmax is None or xmax < x: + xmax = x + if ymin is None or y < ymin: + ymin = y + elif ymax is None or ymax < y: + ymax = y + return xmin, ymin, xmax, ymax + + xmin, ymin, xmax, ymax = process_coords(coordinates) + + return SpatialExtent([[xmin, ymin, xmax, ymax]]) + + +class TemporalExtent: + """Temporal extent. Assumes all times in UTC""" + def __init__(self, intervals): + for i in intervals: + if i[0] is None and i[1] is None: + raise STACError('TemporalExtent interval must have either ' + 'a start or an end time, or both') + self.intervals = intervals + + def to_dict(self): + encoded_intervals = [] + for i in self.intervals: + start = None + end = None + + if i[0]: + start = '{}Z'.format(i[0].replace(microsecond=0).isoformat()) + + if i[1]: + end = '{}Z'.format(i[1].replace(microsecond=0).isoformat()) + + encoded_intervals.append([start, end]) + + return { 'interval': encoded_intervals } + + def clone(self): + return TemporalExtent(intervals=copy(self.intervals)) + + @staticmethod + def from_dict(d): + """Parses temporal extent from list of strings""" + parsed_intervals = [] + for i in d['interval']: + start = None + end = None + + if i[0]: + start = dateutil.parser.parse(i[0]) + if i[1]: + end = dateutil.parser.parse(i[1]) + parsed_intervals.append([start, end]) + + return TemporalExtent(intervals=parsed_intervals) + + @staticmethod + def from_now(): + return TemporalExtent(intervals=[[datetime.utcnow().replace(microsecond=0), None]]) + + +class Provider: + def __init__(self, name, description=None, roles=None, url=None): + self.name = name + self.description = description + self.roles = roles + self.url = url + + def to_dict(self): + d = { 'name': self.name } + if self.description is not None: + d['description'] = self.description + if self.roles is not None: + d['roles'] = self.roles + if self.url is not None: + d['url'] = self.url + + return d + + @staticmethod + def from_dict(d): + return Provider(name=d['name'], + description=d.get('description'), + roles=d.get('roles'), + url=d.get('url')) diff --git a/pystac/eo.py b/pystac/eo.py new file mode 100644 index 000000000..464090415 --- /dev/null +++ b/pystac/eo.py @@ -0,0 +1 @@ +# TODO diff --git a/pystac/io.py b/pystac/io.py new file mode 100644 index 000000000..c8dae51f3 --- /dev/null +++ b/pystac/io.py @@ -0,0 +1,52 @@ +import os +import json + +class STAC_IO: + """Methods used to read and save STAC json. + Allows users of the library to set their own methods + (e.g. for reading and writing from cloud storage) + """ + def default_read_text_method(uri): + with open(uri) as f: + return f.read() + + def default_write_text_method(uri, txt): + with open(uri, 'w') as f: + f.write(txt) + + def default_stac_object_from_dict_method(d): + if 'type' in d: + return Item.from_dict(d) + elif 'extent' in d: + return Collection.from_dict(d) + else: + return Catalog.from_dict(d) + + read_text_method = default_read_text_method + write_text_method = default_write_text_method + + # Replaced in __init__ to account for extension objects. + stac_object_from_dict = default_stac_object_from_dict_method + + # This is set in __init__.py + STAC_OBJECT_CLASSES = None + + @classmethod + def read_text(cls, uri): + return cls.read_text_method(uri) + + @classmethod + def write_text(cls, uri, txt): + cls.write_text_method(uri, txt) + + @classmethod + def read_stac_json(cls, uri, root=None, parent=None): + d = json.loads(STAC_IO.read_text(uri)) + return cls.stac_object_from_dict(d) + + @classmethod + def save_json(cls, uri, json_dict): + dirname = os.path.dirname(uri) + if not os.path.isdir(dirname): + os.makedirs(dirname) + STAC_IO.write_text(uri, json.dumps(json_dict, indent=4)) diff --git a/pystac/item.py b/pystac/item.py new file mode 100644 index 000000000..3816c4d48 --- /dev/null +++ b/pystac/item.py @@ -0,0 +1,189 @@ +from copy import (copy, deepcopy) +import dateutil.parser + +from pystac import STAC_VERSION +from pystac.stac_object import STACObject +from pystac.io import STAC_IO +from pystac.link import Link + +class Item(STACObject): + def __init__(self, + id, + geometry, + bbox, + datetime, + properties, + stac_extensions=None, + href=None): + self.id = id + self.geometry = geometry + self.bbox = bbox + self.datetime = datetime + self.properties = properties + self.stac_extensions = stac_extensions + + self.links = [] + self.assets = {} + + if href is not None: + self.set_self_href(href) + + def __repr__(self): + return ''.format(self.id) + + def get_assets(self): + return dict(self.assets.items()) + + def add_asset(self, key, href, title=None, media_type=None, properties=None): + self.assets[key] = Asset(href, title=title, media_type=media_type, properties=None) + + def set_collection(self, collection): + self.links = [l for l in self.links if l.rel != 'collection'] + self.links.append(Link.collection(collection)) + + def to_dict(self): + links = list(map(lambda x: x.to_dict(), self.links)) + assets = dict(map(lambda x: (x[0], x[1].to_dict()), self.assets.items())) + + self.properties['datetime'] = '{}Z'.format(self.datetime.replace(microsecond=0)) + + d = { + 'type': 'Feature', + 'stac_version': STAC_VERSION, + 'id': self.id, + 'properties': self.properties, + 'geometry': self.geometry, + 'bbox': self.bbox, + 'links': links, + 'assets': assets + } + + if self.stac_extensions is not None: + d['stac_extensions'] = self.stac_extensions + + return d + + def clone(self): + clone = Item(id=self.id, + geometry=deepcopy(self.geometry), + bbox=copy(self.bbox), + datetime=copy(self.datetime), + properties=deepcopy(self.properties), + stac_extensions=deepcopy(self.stac_extensions)) + clone.links = [l.clone() for l in self.links] + clone.assets = dict([(k, a.clone()) for (k, a) in self.assets.items()]) + return clone + + def full_copy(self, root=None, parent=None): + clone = self.clone() + if root: + clone.set_root(root) + if parent: + clone.set_parent(parent) + + collection_link = clone.get_single_link('collection') + if collection_link and root: + collection_link.resolve_stac_object(root=root) + target = root._resolved_objects.get_or_set(collection_link.target) + collection_link.target = target + + return clone + + def save(self): + STAC_IO.save_json(self.get_self_href(), self.to_dict()) + + @staticmethod + def from_dict(d): + id = d['id'] + geometry = d['geometry'] + bbox = d['bbox'] + properties = d['properties'] + stac_extensions = d.get('stac_extensions') + + datetime = properties.get('datetime') + if datetime is None: + raise STACError('Item dict is missing a "datetime" property in the "properties" field') + datetime = dateutil.parser.parse(datetime) + + item = Item(id=id, + geometry=geometry, + bbox=bbox, + datetime=datetime, + properties=properties, + stac_extensions=stac_extensions) + + for l in d['links']: + item.add_link(Link.from_dict(l)) + + for k, v in d['assets'].items(): + item.assets[k] = Asset.from_dict(v) + + return item + + @staticmethod + def from_file(uri): + d = json.loads(STAC_IO.read_text(uri)) + return Item.from_dict(d) + +class Asset: + class MEDIA_TYPE: + TIFF = 'image/tiff' + GEOTIFF = 'image/vnd.stac.geotiff' + COG = 'image/vnd.stac.geotiff; cloud-optimized=true' + JPEG2000 = 'image/jp2' + PNG = 'image/png' + JPEG = 'image/jpeg' + XML = 'application/xml' + JSON = 'application/json' + TEXT = 'text/plain' + GEOJSON = 'application/geo+json' + GEOPACKAGE = 'application/geopackage+sqlite3' + HDF5 = 'application/x-hdf5' # Hierarchical Data Format version 5 + HDF = 'application/x-hdf' # Hierarchical Data Format versions 4 and earlier. + + def __init__(self, href, title=None, media_type=None, properties=None): + self.href = href + self.title = title + self.media_type = media_type + self.properties = None + + def to_dict(self): + d = { + 'href': self.href + } + + if self.media_type is not None: + d['type'] = self.media_type + + if self.title is not None: + d['title'] = self.title + + if self.properties is not None: + for k in properties: + d[k] = properties[k] + + return d + + def clone(self): + return Asset(href=self.href, + title=self.title, + media_type=self.media_type) + + def __repr__(self): + return ''.format(self.href) + + @staticmethod + def from_dict(d): + d = copy(d) + href = d.pop('href') + media_type = d.pop('type', None) + title = d.pop('title', None) + + properties = None + if any(d): + properties = d + + return Asset(href=href, + media_type=media_type, + title=title, + properties=properties) diff --git a/pystac/label.py b/pystac/label.py new file mode 100644 index 000000000..d94210106 --- /dev/null +++ b/pystac/label.py @@ -0,0 +1,275 @@ +"""STAC Model classes for Label extension. +""" +from copy import (copy, deepcopy) + +from pystac import STACError +from pystac.item import Item +from pystac.link import Link + +class LabelType: + VECTOR = 'vector' + RASTER = 'raster' + ALL = [VECTOR, RASTER] + +class LabelItem(Item): + def __init__(self, + id, + geometry, + bbox, + datetime, + properties, + label_description, + label_type, + label_property=None, + label_classes=None, + stac_extensions=None, + href=None, + label_task=None, + label_method=None, + label_overview=None): + if stac_extensions is None: + stac_extensions = [] + if 'label' not in stac_extensions: + stac_extensions.append('label') + super(LabelItem, self).__init__(id=id, + geometry=geometry, + bbox=bbox, + datetime=datetime, + properties=properties, + stac_extensions=stac_extensions, + href=href) + self.label_property = label_property + self.label_classes = label_classes + self.label_description = label_description + self.label_type = label_type + self.label_task = label_task + self.label_method = label_method + self.label_overview = label_overview + + # Be kind if folks didn't use lists for some properties + if self.label_property is not None: + if not type(self.label_property) is list: + self.label_property = [self.label_property] + + if self.label_method is not None: + if not type(self.label_method) is list: + self.label_method = [self.label_method] + + if self.label_task is not None: + if not type(self.label_task) is list: + self.label_task = [self.label_task] + + # Some light validation + if not self.label_type in LabelType.ALL: + raise STACError("label_type must be one of " + "{}; was {}".format(LabelType.ALL, self.label_type)) + + if self.label_type == LabelType.VECTOR: + if self.label_property is None: + raise STACError('label_property must be set for vector label type') + + if self.label_task is not None: + for task in self.label_task: + if task in ['classification', 'detection', 'segmentation']: + if self.label_classes is None: + raise STACError('label_classes must be set ' + 'for task "{}"'.format(self.label_task)) + + def __repr__(self): + return ''.format(self.id) + + def to_dict(self): + d = super(LabelItem, self).to_dict() + d['properties']['label:description'] = self.label_description + d['properties']['label:type'] = self.label_type + d['properties']['label:property'] = self.label_property + if self.label_classes: + d['properties']['label:classes'] = [classes.to_dict() for classes in self.label_classes] + if self.label_task is not None: + d['properties']['label:task'] = self.label_task + if self.label_method is not None: + d['properties']['label:method'] = self.label_method + if self.label_overview is not None: + d['properties']['label:overview'] = self.label_overview.to_dict() + + return d + + def add_source(self, source_item, title=None, assets=None): + properties = None + if assets is not None: + properties = { 'label:assets': assets } + link = Link('source', + source_item, + title=title, + media_type='application/json', + properties=properties) + self.add_link(link) + + def add_labels(self, href, title=None, media_type=None, properties=None): + self.add_asset("labels", + href=href, + title=title, + media_type=media_type, + properties=properties) + + def add_geojson_labels(self, href, title=None, properties=None): + self.add_labels(href, + title=title, + properties=properties, + media_type='application/geo+json') + + def clone(self): + clone = LabelItem(id=self.id, + geometry=deepcopy(self.geometry), + bbox=copy(self.bbox), + datetime=copy(self.datetime), + properties=deepcopy(self.properties), + label_description=self.label_description, + label_type=self.label_type, + label_property=self.label_property, + label_classes=copy(self.label_classes), + stac_extensions=copy(self.stac_extensions), + label_task=self.label_task, + label_method=self.label_method, + label_overview=deepcopy(self.label_overview)) + clone.links = [l.clone() for l in self.links] + clone.assets = dict([(k, a.clone()) for (k, a) in self.assets.items()]) + return clone + + @staticmethod + def from_dict(d): + item = Item.from_dict(d) + props = item.properties + + label_property = props.get('label:property') + label_classes = props.get('label:classes') + if label_classes is not None: + label_classes = [LabelClasses.from_dict(classes) for classes in label_classes] + label_description = props['label:description'] + label_type = props['label:type'] + label_task = props.get('label:task') + label_method = props.get('label:method') + label_overview = props.get('label:overview') + if label_overview is not None: + label_overview = LabelOverview.from_dict(label_overview) + + li = LabelItem(id=item.id, + geometry=item.geometry, + bbox=item.bbox, + datetime=item.datetime, + properties=item.properties, + label_description=label_description, + label_type=label_type, + label_property=label_property, + label_classes=label_classes, + stac_extensions=item.stac_extensions, + label_task=label_task, + label_method=label_method, + label_overview=label_overview) + + li.links = item.links + li.assets = item.assets + + return li + + def full_copy(self, root=None, parent=None): + result = super(LabelItem, self).full_copy(root, parent) + + source_link = result.get_single_link('source') + if source_link and root: + source_link.resolve_stac_object(root=root) + target = root._resolved_objects.get_or_set(source_link.target) + source_link.target = target + + return result + + +class LabelClasses: + def __init__(self, classes, name=None): + self.name = name + self.classes = classes + + def to_dict(self): + return { 'name': self.name, 'classes': self.classes } + + @staticmethod + def from_dict(d): + return LabelClasses(name=d['name'], classes=d['classes']) + +class LabelOverview: + def __init__(self, property_key, counts=None, statistics=None): + self.property_key = property_key + self.counts = counts + self.statistics = statistics + + def merge_counts(self, other): + assert(self.property_key == other.property_key) + + new_counts = None + if self.counts is None: + new_counts = other.counts + else: + if other.counts is None: + new_counts = self.counts + else: + count_by_prop = {} + def add_counts(counts): + for c in counts: + if not c.name in count_by_prop: + count_by_prop[c.name] = c.count + else: + count_by_prop[c.name] += c.count + add_counts(self.counts) + add_counts(other.counts) + new_counts = [LabelCount(k, v) + for k, v in count_by_prop.items()] + return LabelOverview(self.property_key, counts=new_counts) + + def to_dict(self): + d = { 'property_key': self.property_key } + if self.counts: + d['counts'] = [c.to_dict() for c in self.counts] + if self.statistics: + d['statistics'] = self.statistics.to_dict() + + return d + + @staticmethod + def from_dict(d): + counts = d.get('counts') + if counts is not None: + counts = [LabelCount.from_dict(c) for c in counts] + + statistics = d.get('statistics') + if statistics is not None: + statistics = LabelStatistics.from_dict(statistics) + + return LabelOverview(d['property_key'], + counts=counts, + statistics=statistics) + +class LabelCount: + def __init__(self, name, count): + self.name = name + self.count = count + + def to_dict(self): + return { 'name': self.name, + 'count': self.count } + + @staticmethod + def from_dict(d): + return LabelCount(d['name'], d['count']) + +class LabelStatistics: + def __init__(self, name, value): + self.name = name + self.value = value + + def to_dict(self): + return { 'name': self.name, + 'value': self.value } + + @staticmethod + def from_dict(d): + return LabelStatistics(d['name'], d['value']) diff --git a/pystac/link.py b/pystac/link.py new file mode 100644 index 000000000..3475cd9ed --- /dev/null +++ b/pystac/link.py @@ -0,0 +1,134 @@ +import os +from copy import copy +from urllib.parse import urlparse + +from pystac import STACError +from pystac.io import STAC_IO + +class Link: + def __init__(self, rel, target, media_type=None, title=None, properties=None): + self.rel = rel + self.target = target # An object or an href + self.media_type = media_type + self.title = title + self.properties = properties + + def __repr__(self): + return ''.format(self.rel, self.target) + + def resolve_stac_object(self, root=None, parent=None): + if isinstance(self.target, str): + # If it's a relative link, base it off the parent. + target_path = self.target + parsed = urlparse(self.target) + if parsed.scheme == '': + if not os.path.isabs(parsed.path): + if parent is None: + raise STACError('Relative path {} encountered ' + 'without parent.'.format(target_path)) + parent_href = parent.get_self_href() + if parent_href is None: + raise STACError('Relative path {} encountered ' + 'without parent "self" link set.'.format(target_path)) + parsed_parent = urlparse(parent_href) + parent_dir = os.path.dirname(parsed_parent.path) + abs_path = os.path.abspath(os.path.join(parent_dir, target_path)) + if parsed_parent.scheme != '': + target_path = '{}://{}{}'.format(parsed_parent.scheme, + parsed_parent.netloc, + abs_path) + else: + target_path = abs_path + print(target_path) + + obj = STAC_IO.read_stac_json(target_path, root=root, parent=parent) + obj.set_self_href(target_path) + else: + obj = self.target + + if root is not None: + self.target = root._resolved_objects.get_or_set(obj) + self.target.set_root(root) + else: + self.target = obj + + if parent: + self.target.set_parent(parent) + + return self + + def is_resolved(self): + return not isinstance(self.target, str) + + def to_dict(self): + d = { 'rel': self.rel } + if self.is_resolved(): + d['href'] = self.target.get_self_href() + else: + d['href'] = self.target + + if self.media_type is not None: + d['type'] = self.media_type + + if self.title is not None: + d['title'] = self.title + + if self.properties: + for k, v in self.properties.items(): + d[k] = v + + return d + + def clone(self): + return Link(rel=self.rel, + target=self.target, + media_type=self.media_type, + title=self.title) + + @staticmethod + def from_dict(d): + d = copy(d) + rel = d.pop('rel') + href = d.pop('href') + media_type = d.pop('type', None) + title = d.pop('title', None) + + properties = None + if any(d): + properties = d + + return Link(rel=rel, + target=href, + media_type=media_type, + title=title, + properties=properties) + + @staticmethod + def root(c): + """Creates a link to a root Catalog or Collection.""" + return Link('root', c, media_type='application/json') + + @staticmethod + def parent(c): + """Creates a link to a parent Catalog or Collection.""" + return Link('parent', c, media_type='application/json') + + @staticmethod + def collection(c): + """Creates a link to an item's Collection.""" + return Link('collection', c, media_type='application/json') + + @staticmethod + def self_href(href): + """Creates a self link to the file's location.""" + return Link('self', href, media_type='application/json') + + @staticmethod + def child(c, title=None): + """Creates a link to a child Catalog or Collection.""" + return Link('child', c, title=title, media_type='application/json') + + @staticmethod + def item(item, title=None): + """Creates a link to an Item.""" + return Link('item', item, title=title, media_type='application/json') diff --git a/pystac/resolved_object_cache.py b/pystac/resolved_object_cache.py new file mode 100644 index 000000000..964e582d8 --- /dev/null +++ b/pystac/resolved_object_cache.py @@ -0,0 +1,48 @@ +from collections import ChainMap +from copy import (copy, deepcopy) + +# TODO: Version fo this that doesn't track in case users don't want tracked resolving. +class ResolvedObjectCache: + """This class tracks resolved objects tied to root catalogs. + A STAC object is 'resolved' when it is a Python Object; a link + to a STAC object such as a Catalog or Item is considered "unresolved" + if it's target is pointed at an HREF of the object. + + Tracking resolved objects allows us to tie together the same instances + when there are loops in the Graph of the STAC catalog (e.g. a LabelItem + can link to a rel:source, and if that STAC Item exists in the same + root catalog they should refer to the same Python object). + + Resolution tracking is important when copying STACs in-memory: In order + for object links to refer to the copy of STAC Objects rather than their + originals, we have to keep track of the resolved STAC Objects and replace + them with their copies. + """ + def __init__(self, ids_to_objects=None): + self.ids_to_objects = ids_to_objects or {} + + # TODO: Is it ok to just use the STAC Object ID? Or can IDs be non-unique inside + # root catalogs? + def get_or_set(self, obj): + if obj.id in self.ids_to_objects: + return self.ids_to_objects[obj.id] + else: + self.ids_to_objects[obj.id] = obj + return obj + + def get(self, obj): + return self.ids_to_objects.get(obj.id) + + def set(self, obj): + self.ids_to_objects[obj.id] = obj + + def clone(self): + return ResolvedObjectCache(copy(self.ids_to_objects)) + + def __contains__(self, obj): + return obj.id in self.ids_to_objects + + @staticmethod + def merge(first, second): + return ResolvedObjectCache(dict(ChainMap(copy(first.ids_to_objects), + copy(second.ids_to_objects)))) diff --git a/pystac/stac_object.py b/pystac/stac_object.py new file mode 100644 index 000000000..c8e6f361d --- /dev/null +++ b/pystac/stac_object.py @@ -0,0 +1,100 @@ +from abc import (ABC, abstractmethod) +from pystac.link import Link + +class STACObject(ABC): + """A STAC Object has links, can can be cloned or copied.""" + + def __init__(self): + self.links = [] + + def add_link(self, link): + self.links.append(link) + + def add_links(self, links): + self.links.extend(links) + + def get_single_link(self, rel): + return next((l for l in self.links if l.rel == rel), None) + + def get_root(self): + root_link = self.get_single_link('root') + if root_link: + return root_link.resolve_stac_object().target + else: + return None + + def set_root(self, root): + self.links = [l for l in self.links if l.rel != 'root'] + self.links.append(Link.root(root)) + + def get_parent(self): + parent_link = self.get_single_link('parent') + if parent_link: + return parent_link.resolve_stac_object().target + else: + return None + + def set_parent(self, parent): + self.links = [l for l in self.links if l.rel != 'parent'] + self.links.append(Link.parent(parent)) + + def get_self_href(self): + self_link = self.get_single_link('self') + if self_link: + return self_link.target + else: + return None + + def set_self_href(self, href): + self.links = [l for l in self.links if l.rel != 'self'] + self.links.append(Link.self_href(href)) + + def get_stac_objects(self, rel, parent=None): + result = [] + for i in range(0, len(self.links)): + link = self.links[i] + if link.rel == rel: + link.resolve_stac_object(root=self.get_root(), parent=parent) + result.append(link.target) + return result + + def get_links(self, rel=None): + if rel is None: + return self.links + else: + return [l for l in self.links if l.rel == rel] + + def clear_links(self, rel=None): + if rel is not None: + self.links = [l for l in self.links if l.rel != rel] + else: + self.links = [] + + def full_copy(self, root=None, parent=None): + clone = self.clone() + + if root is None: + root = clone + clone.set_root(root) + if parent: + clone.set_parent(parent) + + for link in (clone.get_child_links() + clone.get_item_links()): + link.resolve_stac_object() + target = link.target + if target in root._resolved_objects: + target = root._resolved_objects.get(target) + else: + copied_target = target.full_copy(root=root, parent=clone) + root._resolved_objects.set(copied_target) + target = copied_target + if link.rel in ['child', 'item']: + target.set_root(root) + target.set_parent(clone) + link.target = target + + return clone + + @abstractmethod + def clone(self): + pass diff --git a/pystac/version.py b/pystac/version.py new file mode 100644 index 000000000..0fc6ed8ad --- /dev/null +++ b/pystac/version.py @@ -0,0 +1,5 @@ +"""Library verison""" +__version__ = '0.1.0' + +"""STAC version""" +STAC_VERSION = '0.8.0' diff --git a/reqirements-dev.txt b/reqirements-dev.txt new file mode 100644 index 000000000..391854a49 --- /dev/null +++ b/reqirements-dev.txt @@ -0,0 +1 @@ +jsonschema==3.0.2 diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..7bb1aa6aa --- /dev/null +++ b/setup.py @@ -0,0 +1,47 @@ +from imp import load_source +from setuptools import setup, find_packages +from glob import glob + +__version__ = load_source('pystac.version', 'pystac/version.py').__version__ + +from os.path import ( + basename, + splitext +) + +with open('README.md') as readme_file: + readme = readme_file.read() + +setup( + name='pystac', + version=__version__, + description=("Python library for working with Spatiotemporal Asset Catalog (STAC)."), + long_description=readme, + author="Azaveea", + author_email='info@azavea.com', + url='https://github.com/azavea/pystac.git', + packages=find_packages(), + py_modules=[splitext(basename(path))[0] for path in glob('pystac/*.py')], + include_package_data=False, + install_requires=[], + license="Apache Software License 2.0", + zip_safe=False, + keywords=[ + 'pystac', + 'imagery', + 'raster', + 'catalog', + 'STAC' + ], + classifiers=[ + 'Development Status :: 2 - Pre-Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Natural Language :: English', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + ], + test_suite='tests' +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..5c3f4a6a1 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,24 @@ +import ssl +from urllib.request import urlopen +from urllib.parse import urlparse + +from pystac.io import STAC_IO + +# Set the STAC_IO read method to read HTTP. +# Skip SSL Certification because it fails on some machines. + +def unsafe_read_https_method(uri): + parsed = urlparse(uri) + if parsed.scheme == 'https': + context = ssl._create_unverified_context() + with urlopen(uri, context=context) as f: + return f.read().decode('utf-8') + elif parsed.scheme == 'http': + with urlopen(uri) as f: + return f.read().decode('utf-8') + else: + with open(uri) as f: + return f.read() + + +STAC_IO.read_text_method = unsafe_read_https_method diff --git a/tests/data-files/catalogs/testcase1/catalog.json b/tests/data-files/catalogs/testcase1/catalog.json new file mode 100644 index 000000000..f16dff216 --- /dev/null +++ b/tests/data-files/catalogs/testcase1/catalog.json @@ -0,0 +1,27 @@ +{ + "id": "test", + "stac_version": "0.8.0", + "description": "test catalog", + "links": [ + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "child", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/catalog.json", + "type": "application/json" + }, + { + "rel": "child", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/catalog.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + } + ] +} \ No newline at end of file diff --git a/tests/data-files/catalogs/testcase1/country-1/area-1-1/area-1-1-imagery.json b/tests/data-files/catalogs/testcase1/country-1/area-1-1/area-1-1-imagery.json new file mode 100644 index 000000000..c4e0d8241 --- /dev/null +++ b/tests/data-files/catalogs/testcase1/country-1/area-1-1/area-1-1-imagery.json @@ -0,0 +1,73 @@ +{ + "type": "Feature", + "stac_version": "0.8.0", + "id": "area-1-1-imagery", + "properties": { + "datetime": "2019-09-11 19:21:32Z" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -2.5048828125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 4.275202171119132 + ], + [ + -2.5048828125, + 4.275202171119132 + ], + [ + -2.5048828125, + 3.8916575492899987 + ] + ] + ] + }, + "bbox": [ + -2.5048828125, + 3.8916575492899987, + -1.9610595703125, + 4.275202171119132 + ], + "links": [ + { + "rel": "collection", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-1/collection.json", + "type": "application/json" + }, + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-1/collection.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-1/area-1-1-imagery.json", + "type": "application/json" + } + ], + "assets": { + "ortho": { + "href": "area-1-1_ortho.tif", + "type": "image/vnd.stac.geotiff" + }, + "dsm": { + "href": "area-1-1_dsm.tif", + "type": "image/vnd.stac.geotiff" + } + } +} \ No newline at end of file diff --git a/tests/data-files/catalogs/testcase1/country-1/area-1-1/area-1-1-labels.json b/tests/data-files/catalogs/testcase1/country-1/area-1-1/area-1-1-labels.json new file mode 100644 index 000000000..bf65abe9e --- /dev/null +++ b/tests/data-files/catalogs/testcase1/country-1/area-1-1/area-1-1-labels.json @@ -0,0 +1,95 @@ +{ + "type": "Feature", + "stac_version": "0.8.0", + "id": "area-1-1-labels", + "properties": { + "datetime": "2019-09-11 19:21:32Z", + "label:description": "labels for area-1-1", + "label:type": "vector", + "label:property": [ + "label" + ], + "label:classes": [ + { + "name": "label", + "classes": [ + "one", + "two" + ] + } + ], + "label:task": [ + "classification" + ], + "label:method": [ + "manual" + ] + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -2.5048828125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 4.275202171119132 + ], + [ + -2.5048828125, + 4.275202171119132 + ], + [ + -2.5048828125, + 3.8916575492899987 + ] + ] + ] + }, + "bbox": [ + -2.5048828125, + 3.8916575492899987, + -1.9610595703125, + 4.275202171119132 + ], + "links": [ + { + "rel": "source", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-1/area-1-1-imagery.json", + "type": "application/json", + "label:assets": [ + "ortho" + ] + }, + { + "rel": "collection", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-1/collection.json", + "type": "application/json" + }, + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-1/collection.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-1/area-1-1-labels.json", + "type": "application/json" + } + ], + "assets": {}, + "stac_extensions": [ + "label" + ] +} \ No newline at end of file diff --git a/tests/data-files/catalogs/testcase1/country-1/area-1-1/collection.json b/tests/data-files/catalogs/testcase1/country-1/area-1-1/collection.json new file mode 100644 index 000000000..58b69b38c --- /dev/null +++ b/tests/data-files/catalogs/testcase1/country-1/area-1-1/collection.json @@ -0,0 +1,53 @@ +{ + "id": "area-1-1", + "stac_version": "0.8.0", + "description": "test collection country-1", + "links": [ + { + "rel": "item", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-1/area-1-1-imagery.json", + "type": "application/json" + }, + { + "rel": "item", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-1/area-1-1-labels.json", + "type": "application/json" + }, + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/catalog.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-1/collection.json", + "type": "application/json" + } + ], + "extent": { + "spatial": { + "bbox": [ + [ + -2.5048828125, + 3.8916575492899987, + -1.9610595703125, + 4.275202171119132 + ] + ] + }, + "temporal": { + "interval": [ + [ + "2019-09-11T19:21:31Z", + null + ] + ] + } + }, + "license": "proprietary" +} \ No newline at end of file diff --git a/tests/data-files/catalogs/testcase1/country-1/area-1-2/area-1-2-imagery.json b/tests/data-files/catalogs/testcase1/country-1/area-1-2/area-1-2-imagery.json new file mode 100644 index 000000000..f21587c3c --- /dev/null +++ b/tests/data-files/catalogs/testcase1/country-1/area-1-2/area-1-2-imagery.json @@ -0,0 +1,73 @@ +{ + "type": "Feature", + "stac_version": "0.8.0", + "id": "area-1-2-imagery", + "properties": { + "datetime": "2019-09-11 19:21:32Z" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -2.5048828125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 4.275202171119132 + ], + [ + -2.5048828125, + 4.275202171119132 + ], + [ + -2.5048828125, + 3.8916575492899987 + ] + ] + ] + }, + "bbox": [ + -2.5048828125, + 3.8916575492899987, + -1.9610595703125, + 4.275202171119132 + ], + "links": [ + { + "rel": "collection", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-2/collection.json", + "type": "application/json" + }, + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-2/collection.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-2/area-1-2-imagery.json", + "type": "application/json" + } + ], + "assets": { + "ortho": { + "href": "area-1-2_ortho.tif", + "type": "image/vnd.stac.geotiff" + }, + "dsm": { + "href": "area-1-2_dsm.tif", + "type": "image/vnd.stac.geotiff" + } + } +} \ No newline at end of file diff --git a/tests/data-files/catalogs/testcase1/country-1/area-1-2/area-1-2-labels.json b/tests/data-files/catalogs/testcase1/country-1/area-1-2/area-1-2-labels.json new file mode 100644 index 000000000..7870bdc25 --- /dev/null +++ b/tests/data-files/catalogs/testcase1/country-1/area-1-2/area-1-2-labels.json @@ -0,0 +1,95 @@ +{ + "type": "Feature", + "stac_version": "0.8.0", + "id": "area-1-2-labels", + "properties": { + "datetime": "2019-09-11 19:21:32Z", + "label:description": "labels for area-1-2", + "label:type": "vector", + "label:property": [ + "label" + ], + "label:classes": [ + { + "name": "label", + "classes": [ + "one", + "two" + ] + } + ], + "label:task": [ + "classification" + ], + "label:method": [ + "manual" + ] + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -2.5048828125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 4.275202171119132 + ], + [ + -2.5048828125, + 4.275202171119132 + ], + [ + -2.5048828125, + 3.8916575492899987 + ] + ] + ] + }, + "bbox": [ + -2.5048828125, + 3.8916575492899987, + -1.9610595703125, + 4.275202171119132 + ], + "links": [ + { + "rel": "source", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-2/area-1-2-imagery.json", + "type": "application/json", + "label:assets": [ + "ortho" + ] + }, + { + "rel": "collection", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-2/collection.json", + "type": "application/json" + }, + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-2/collection.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-2/area-1-2-labels.json", + "type": "application/json" + } + ], + "assets": {}, + "stac_extensions": [ + "label" + ] +} \ No newline at end of file diff --git a/tests/data-files/catalogs/testcase1/country-1/area-1-2/collection.json b/tests/data-files/catalogs/testcase1/country-1/area-1-2/collection.json new file mode 100644 index 000000000..2a153c2b9 --- /dev/null +++ b/tests/data-files/catalogs/testcase1/country-1/area-1-2/collection.json @@ -0,0 +1,53 @@ +{ + "id": "area-1-2", + "stac_version": "0.8.0", + "description": "test collection country-1", + "links": [ + { + "rel": "item", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-2/area-1-2-imagery.json", + "type": "application/json" + }, + { + "rel": "item", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-2/area-1-2-labels.json", + "type": "application/json" + }, + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/catalog.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-2/collection.json", + "type": "application/json" + } + ], + "extent": { + "spatial": { + "bbox": [ + [ + -2.5048828125, + 3.8916575492899987, + -1.9610595703125, + 4.275202171119132 + ] + ] + }, + "temporal": { + "interval": [ + [ + "2019-09-11T19:21:31Z", + null + ] + ] + } + }, + "license": "proprietary" +} \ No newline at end of file diff --git a/tests/data-files/catalogs/testcase1/country-1/catalog.json b/tests/data-files/catalogs/testcase1/country-1/catalog.json new file mode 100644 index 000000000..a675fcd8e --- /dev/null +++ b/tests/data-files/catalogs/testcase1/country-1/catalog.json @@ -0,0 +1,32 @@ +{ + "id": "country-1", + "stac_version": "0.8.0", + "description": "test catalog country-1", + "links": [ + { + "rel": "child", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-1/collection.json", + "type": "application/json" + }, + { + "rel": "child", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/area-1-2/collection.json", + "type": "application/json" + }, + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-1/catalog.json", + "type": "application/json" + } + ] +} \ No newline at end of file diff --git a/tests/data-files/catalogs/testcase1/country-2/area-2-1/area-2-1-imagery.json b/tests/data-files/catalogs/testcase1/country-2/area-2-1/area-2-1-imagery.json new file mode 100644 index 000000000..7498414f6 --- /dev/null +++ b/tests/data-files/catalogs/testcase1/country-2/area-2-1/area-2-1-imagery.json @@ -0,0 +1,73 @@ +{ + "type": "Feature", + "stac_version": "0.8.0", + "id": "area-2-1-imagery", + "properties": { + "datetime": "2019-09-11 19:21:32Z" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -2.5048828125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 4.275202171119132 + ], + [ + -2.5048828125, + 4.275202171119132 + ], + [ + -2.5048828125, + 3.8916575492899987 + ] + ] + ] + }, + "bbox": [ + -2.5048828125, + 3.8916575492899987, + -1.9610595703125, + 4.275202171119132 + ], + "links": [ + { + "rel": "collection", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-1/collection.json", + "type": "application/json" + }, + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-1/collection.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-1/area-2-1-imagery.json", + "type": "application/json" + } + ], + "assets": { + "ortho": { + "href": "area-2-1_ortho.tif", + "type": "image/vnd.stac.geotiff" + }, + "dsm": { + "href": "area-2-1_dsm.tif", + "type": "image/vnd.stac.geotiff" + } + } +} \ No newline at end of file diff --git a/tests/data-files/catalogs/testcase1/country-2/area-2-1/area-2-1-labels.json b/tests/data-files/catalogs/testcase1/country-2/area-2-1/area-2-1-labels.json new file mode 100644 index 000000000..a808e7805 --- /dev/null +++ b/tests/data-files/catalogs/testcase1/country-2/area-2-1/area-2-1-labels.json @@ -0,0 +1,95 @@ +{ + "type": "Feature", + "stac_version": "0.8.0", + "id": "area-2-1-labels", + "properties": { + "datetime": "2019-09-11 19:21:32Z", + "label:description": "labels for area-2-1", + "label:type": "vector", + "label:property": [ + "label" + ], + "label:classes": [ + { + "name": "label", + "classes": [ + "one", + "two" + ] + } + ], + "label:task": [ + "classification" + ], + "label:method": [ + "manual" + ] + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -2.5048828125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 4.275202171119132 + ], + [ + -2.5048828125, + 4.275202171119132 + ], + [ + -2.5048828125, + 3.8916575492899987 + ] + ] + ] + }, + "bbox": [ + -2.5048828125, + 3.8916575492899987, + -1.9610595703125, + 4.275202171119132 + ], + "links": [ + { + "rel": "source", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-1/area-2-1-imagery.json", + "type": "application/json", + "label:assets": [ + "ortho" + ] + }, + { + "rel": "collection", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-1/collection.json", + "type": "application/json" + }, + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-1/collection.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-1/area-2-1-labels.json", + "type": "application/json" + } + ], + "assets": {}, + "stac_extensions": [ + "label" + ] +} \ No newline at end of file diff --git a/tests/data-files/catalogs/testcase1/country-2/area-2-1/collection.json b/tests/data-files/catalogs/testcase1/country-2/area-2-1/collection.json new file mode 100644 index 000000000..9de26ecc7 --- /dev/null +++ b/tests/data-files/catalogs/testcase1/country-2/area-2-1/collection.json @@ -0,0 +1,53 @@ +{ + "id": "area-2-1", + "stac_version": "0.8.0", + "description": "test collection country-2", + "links": [ + { + "rel": "item", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-1/area-2-1-imagery.json", + "type": "application/json" + }, + { + "rel": "item", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-1/area-2-1-labels.json", + "type": "application/json" + }, + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/catalog.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-1/collection.json", + "type": "application/json" + } + ], + "extent": { + "spatial": { + "bbox": [ + [ + -2.5048828125, + 3.8916575492899987, + -1.9610595703125, + 4.275202171119132 + ] + ] + }, + "temporal": { + "interval": [ + [ + "2019-09-11T19:21:31Z", + null + ] + ] + } + }, + "license": "proprietary" +} \ No newline at end of file diff --git a/tests/data-files/catalogs/testcase1/country-2/area-2-2/area-2-2-imagery.json b/tests/data-files/catalogs/testcase1/country-2/area-2-2/area-2-2-imagery.json new file mode 100644 index 000000000..45981064a --- /dev/null +++ b/tests/data-files/catalogs/testcase1/country-2/area-2-2/area-2-2-imagery.json @@ -0,0 +1,73 @@ +{ + "type": "Feature", + "stac_version": "0.8.0", + "id": "area-2-2-imagery", + "properties": { + "datetime": "2019-09-11 19:21:32Z" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -2.5048828125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 4.275202171119132 + ], + [ + -2.5048828125, + 4.275202171119132 + ], + [ + -2.5048828125, + 3.8916575492899987 + ] + ] + ] + }, + "bbox": [ + -2.5048828125, + 3.8916575492899987, + -1.9610595703125, + 4.275202171119132 + ], + "links": [ + { + "rel": "collection", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-2/collection.json", + "type": "application/json" + }, + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-2/collection.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-2/area-2-2-imagery.json", + "type": "application/json" + } + ], + "assets": { + "ortho": { + "href": "area-2-2_ortho.tif", + "type": "image/vnd.stac.geotiff" + }, + "dsm": { + "href": "area-2-2_dsm.tif", + "type": "image/vnd.stac.geotiff" + } + } +} \ No newline at end of file diff --git a/tests/data-files/catalogs/testcase1/country-2/area-2-2/area-2-2-labels.json b/tests/data-files/catalogs/testcase1/country-2/area-2-2/area-2-2-labels.json new file mode 100644 index 000000000..4b27d5864 --- /dev/null +++ b/tests/data-files/catalogs/testcase1/country-2/area-2-2/area-2-2-labels.json @@ -0,0 +1,95 @@ +{ + "type": "Feature", + "stac_version": "0.8.0", + "id": "area-2-2-labels", + "properties": { + "datetime": "2019-09-11 19:21:32Z", + "label:description": "labels for area-2-2", + "label:type": "vector", + "label:property": [ + "label" + ], + "label:classes": [ + { + "name": "label", + "classes": [ + "one", + "two" + ] + } + ], + "label:task": [ + "classification" + ], + "label:method": [ + "manual" + ] + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -2.5048828125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 4.275202171119132 + ], + [ + -2.5048828125, + 4.275202171119132 + ], + [ + -2.5048828125, + 3.8916575492899987 + ] + ] + ] + }, + "bbox": [ + -2.5048828125, + 3.8916575492899987, + -1.9610595703125, + 4.275202171119132 + ], + "links": [ + { + "rel": "source", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-2/area-2-2-imagery.json", + "type": "application/json", + "label:assets": [ + "ortho" + ] + }, + { + "rel": "collection", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-2/collection.json", + "type": "application/json" + }, + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-2/collection.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-2/area-2-2-labels.json", + "type": "application/json" + } + ], + "assets": {}, + "stac_extensions": [ + "label" + ] +} \ No newline at end of file diff --git a/tests/data-files/catalogs/testcase1/country-2/area-2-2/collection.json b/tests/data-files/catalogs/testcase1/country-2/area-2-2/collection.json new file mode 100644 index 000000000..956718990 --- /dev/null +++ b/tests/data-files/catalogs/testcase1/country-2/area-2-2/collection.json @@ -0,0 +1,53 @@ +{ + "id": "area-2-2", + "stac_version": "0.8.0", + "description": "test collection country-2", + "links": [ + { + "rel": "item", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-2/area-2-2-imagery.json", + "type": "application/json" + }, + { + "rel": "item", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-2/area-2-2-labels.json", + "type": "application/json" + }, + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/catalog.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-2/collection.json", + "type": "application/json" + } + ], + "extent": { + "spatial": { + "bbox": [ + [ + -2.5048828125, + 3.8916575492899987, + -1.9610595703125, + 4.275202171119132 + ] + ] + }, + "temporal": { + "interval": [ + [ + "2019-09-11T19:21:31Z", + null + ] + ] + } + }, + "license": "proprietary" +} \ No newline at end of file diff --git a/tests/data-files/catalogs/testcase1/country-2/catalog.json b/tests/data-files/catalogs/testcase1/country-2/catalog.json new file mode 100644 index 000000000..e855ce258 --- /dev/null +++ b/tests/data-files/catalogs/testcase1/country-2/catalog.json @@ -0,0 +1,32 @@ +{ + "id": "country-2", + "stac_version": "0.8.0", + "description": "test catalog country-2", + "links": [ + { + "rel": "child", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-1/collection.json", + "type": "application/json" + }, + { + "rel": "child", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/area-2-2/collection.json", + "type": "application/json" + }, + { + "rel": "root", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "parent", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/catalog.json", + "type": "application/json" + }, + { + "rel": "self", + "href": "/Users/rob/proj/stac/pystac/tests/data-files/catalogs/testcase1/country-2/catalog.json", + "type": "application/json" + } + ] +} \ No newline at end of file diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 000000000..5c9ffeb7d --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,95 @@ +import os +import unittest +from tempfile import TemporaryDirectory + +from pystac import * +from tests.utils import (TestCases, RANDOM_GEOM, RANDOM_BBOX) + +class CatalogTest(unittest.TestCase): + def test_create_and_read(self): + with TemporaryDirectory() as tmp_dir: + cat_dir = os.path.join(tmp_dir, 'catalog') + catalog = TestCases.test_case_1() + + catalog.set_uris_from_root(cat_dir) + catalog.save() + + read_catalog = Catalog.from_file('{}/catalog.json'.format(cat_dir)) + + collections = catalog.get_children() + self.assertEqual(len(collections), 2) + + items = read_catalog.get_all_items() + + self.assertEqual(len(items), 8) + + def test_read_remote(self): + catalog_url = ('https://raw.githubusercontent.com/radiantearth/stac-spec/' + '252cc892cdccf7ba0b9564bcae42bb6ec4189f14' + '/extensions/label/examples/multidataset/catalog.json') + cat = Catalog.from_file(catalog_url) + + zanzibar = cat.get_child('zanzibar-collection') + + self.assertEqual(len(zanzibar.get_items()), 2) + + + def test_map_items(self): + def item_mapper(item): + item.properties['ITEM_MAPPER'] = 'YEP' + return item + + with TemporaryDirectory() as tmp_dir: + catalog = TestCases.test_case_1() + + new_cat = catalog.map_items(item_mapper) + + new_cat.set_uris_from_root(os.path.join(tmp_dir, 'cat')) + new_cat.save() + + result_cat = Catalog.from_file(os.path.join(tmp_dir, 'cat', 'catalog.json')) + + for item in result_cat.get_all_items(): + self.assertTrue('ITEM_MAPPER' in item.properties) + + for item in catalog.get_all_items(): + self.assertFalse('ITEM_MAPPER' in item.properties) + + def test_map_items_multiple(self): + def item_mapper(item): + item2 = item.clone() + item2.id = item2.id + '_2' + item.properties['ITEM_MAPPER_1'] = 'YEP' + item2.properties['ITEM_MAPPER_2'] = 'YEP' + return [item, item2] + + with TemporaryDirectory() as tmp_dir: + catalog = TestCases.test_case_1() + catalog_items = catalog.get_all_items() + + new_cat = catalog.map_items(item_mapper) + + + new_cat.set_uris_from_root(os.path.join(tmp_dir, 'cat')) + new_cat.save() + + result_cat = Catalog.from_file(os.path.join(tmp_dir, 'cat', 'catalog.json')) + result_items = result_cat.get_all_items() + + self.assertEqual(len(catalog_items)*2, len(result_items)) + + ones, twos = 0, 0 + for item in result_items: + self.assertTrue(('ITEM_MAPPER_1' in item.properties) or + ('ITEM_MAPPER_2' in item.properties)) + if 'ITEM_MAPPER_1' in item.properties: + ones += 1 + + if 'ITEM_MAPPER_2' in item.properties: + twos += 1 + + self.assertEqual(ones, twos) + + for item in catalog.get_all_items(): + self.assertFalse(('ITEM_MAPPER_1' in item.properties) or + ('ITEM_MAPPER_2' in item.properties)) diff --git a/tests/test_collection.py b/tests/test_collection.py new file mode 100644 index 000000000..6fcd8b10b --- /dev/null +++ b/tests/test_collection.py @@ -0,0 +1,16 @@ +import os +import unittest +from tempfile import TemporaryDirectory + +from pystac import * +from tests.utils import (TestCases, RANDOM_GEOM, RANDOM_BBOX) + +class CollectionTest(unittest.TestCase): + def test_spatial_extent_from_coordinates(self): + extent = SpatialExtent.from_coordinates(RANDOM_GEOM['coordinates']) + + self.assertEqual(len(extent.bboxes), 1) + bbox = extent.bboxes[0] + self.assertEqual(len(bbox), 4) + for x in bbox: + self.assertTrue(type(x) is float) diff --git a/tests/test_stac_object.py b/tests/test_stac_object.py new file mode 100644 index 000000000..b42675ee5 --- /dev/null +++ b/tests/test_stac_object.py @@ -0,0 +1,101 @@ +import json +import os +import unittest +import shutil +from tempfile import TemporaryDirectory +from datetime import datetime + +from pystac import * +from tests.utils import (TestCases, RANDOM_GEOM, RANDOM_BBOX) + + +class FullCopyTest(unittest.TestCase): + def check_link(self, l, tag): + if l.is_resolved(): + target_href = l.target.get_self_href() + else: + target_href = l.target + self.assertTrue(tag in target_href) + + def check_item(self, i, tag): + for l in i.links: + self.check_link(l, tag) + + def check_catalog(self, c, tag): + self.assertEqual(len(c.get_links('root')), 1) + + for l in c.links: + self.check_link(l, tag) + + for child in c.get_children(): + self.check_catalog(child, tag) + + for item in c.get_items(): + self.check_item(item, tag) + + def test_full_copy_1(self): + with TemporaryDirectory() as tmp_dir: + cat = Catalog(id='test', description='test catalog') + + item = Item(id='test_item', + geometry=RANDOM_GEOM, + bbox=RANDOM_BBOX, + datetime=datetime.utcnow(), + properties={}) + + cat.set_uris_from_root(os.path.join(tmp_dir, 'catalog-full-copy-1-source')) + cat2 = cat.full_copy() + cat2.set_uris_from_root(os.path.join(tmp_dir, 'catalog-full-copy-1-dest')) + + self.check_catalog(cat, 'source') + self.check_catalog(cat2, 'dest') + + def test_full_copy_2(self): + with TemporaryDirectory() as tmp_dir: + cat = Catalog(id='test', description='test catalog') + image_item = Item(id='Imagery', + geometry=RANDOM_GEOM, + bbox=RANDOM_BBOX, + datetime=datetime.utcnow(), + properties={}) + for key in ['ortho', 'dsm']: + image_item.add_asset(key, + href='some/{}.tif'.format(key), + media_type=Asset.MEDIA_TYPE.GEOTIFF) + + label_item = LabelItem(id='Labels', + geometry=RANDOM_GEOM, + bbox=RANDOM_BBOX, + datetime=datetime.utcnow(), + properties={}, + label_description='labels', + label_type='vector', + label_property='label', + label_classes=[LabelClasses(classes=['one', 'two'], + name='label')], + label_task='classification') + label_item.add_source(image_item, assets=['ortho']) + + cat.add_items([image_item, label_item]) + + cat.set_uris_from_root(os.path.join(tmp_dir, 'catalog-full-copy-2-source')) + cat.save() + cat2 = cat.full_copy() + cat2.set_uris_from_root(os.path.join(tmp_dir, 'catalog-full-copy-2-dest')) + cat2.save() + + self.check_catalog(cat, 'source') + self.check_catalog(cat2, 'dest') + + + def test_full_copy_3(self): + with TemporaryDirectory() as tmp_dir: + root_cat = TestCases.test_case_1() + root_cat.set_uris_from_root(os.path.join(tmp_dir, 'catalog-full-copy-3-source')) + root_cat.save() + cat2 = root_cat.full_copy() + cat2.set_uris_from_root(os.path.join(tmp_dir, 'catalog-full-copy-3-dest')) + cat2.save() + + self.check_catalog(root_cat, 'source') + self.check_catalog(cat2, 'dest') diff --git a/tests/test_writing.py b/tests/test_writing.py new file mode 100644 index 000000000..7d86244bc --- /dev/null +++ b/tests/test_writing.py @@ -0,0 +1,27 @@ +import unittest + +from tests.utils import (TestCases, SchemaValidator) + +class STACWriteTest(unittest.TestCase): + def setUp(self): + self.schema_validator = SchemaValidator() + + + def validate_catalog(self, catalog): + self.schema_validator.validate(catalog) + validated_count = 1 + + for child in catalog.get_children(): + validated_count += self.validate_catalog(child) + + for item in catalog.get_items(): + self.schema_validator.validate(item) + validated_count += 1 + + return validated_count + + """Tests writing STACs, using JSON Schema validation""" + def test_testcase1(self): + catalog = TestCases.test_case_1() + catalog.set_uris_from_root('/dev/null') + self.validate_catalog(catalog) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 000000000..9a336221b --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,155 @@ +import os +import json +from datetime import datetime + +from shapely.geometry import shape +import jsonschema +from jsonschema.validators import RefResolver + +from pystac import * + +TEST_LABEL_CATALOG = { + 'country-1': { + 'area-1-1': { + 'dsm': 'area-1-1_dsm.tif', + 'ortho': 'area-1-1_ortho.tif', + 'labels': 'area-1-1_labels.geojson' }, + 'area-1-2': { + 'dsm': 'area-1-2_dsm.tif', + 'ortho': 'area-1-2_ortho.tif', + 'labels': 'area-1-2_labels.geojson' } }, + 'country-2': { + 'area-2-1': { + 'dsm': 'area-2-1_dsm.tif', + 'ortho': 'area-2-1_ortho.tif', + 'labels': 'area-2-1_labels.geojson' }, + 'area-2-2': { + 'dsm': 'area-2-2_dsm.tif', + 'ortho': 'area-2-2_ortho.tif', + 'labels': 'area-2-2_labels.geojson' } } +} + + + +RANDOM_GEOM = { + "type": "Polygon", + "coordinates": [ + [ + [ + -2.5048828125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 3.8916575492899987 + ], + [ + -1.9610595703125, + 4.275202171119132 + ], + [ + -2.5048828125, + 4.275202171119132 + ], + [ + -2.5048828125, + 3.8916575492899987 + ] + ] + ] +} + +RANDOM_BBOX = list(shape(RANDOM_GEOM).envelope.bounds) + +RANDOM_EXTENT = Extent(spatial=SpatialExtent.from_coordinates(RANDOM_GEOM['coordinates']), + temporal=TemporalExtent.from_now()) + +class TestCases: + @staticmethod + def get_path(rel_path): + return os.path.join(os.path.dirname(__file__), rel_path) + + @staticmethod + def test_case_1(): + root_cat = Catalog(id='test', description='test catalog') + for country in TEST_LABEL_CATALOG: + country_cat = Catalog(id=country, description='test catalog {}'.format(country)) + for area in TEST_LABEL_CATALOG[country]: + area_collection = Collection(id=area, + description='test collection {}'.format(country), + extent=RANDOM_EXTENT) + image_item = Item(id='{}-imagery'.format(area), + geometry=RANDOM_GEOM, + bbox=RANDOM_BBOX, + datetime=datetime.utcnow(), + properties={}) + for key in ['ortho', 'dsm']: + image_item.add_asset(key, + href=TEST_LABEL_CATALOG[country][area][key], + media_type=Asset.MEDIA_TYPE.GEOTIFF) + + label_item = LabelItem(id='{}-labels'.format(area), + geometry=RANDOM_GEOM, + bbox=RANDOM_BBOX, + datetime=datetime.utcnow(), + properties={}, + label_description='labels for {}'.format(area), + label_type='vector', + label_property=['label'], + label_classes=[LabelClasses(classes=['one', 'two'], + name='label')], + label_task=['classification'], + label_method=['manual']) + label_item.add_source(image_item, assets=['ortho']) + + area_collection.add_item(image_item) + area_collection.add_item(label_item) + country_cat.add_child(area_collection) + root_cat.add_child(country_cat) + + return root_cat + +class SchemaValidator: + REPO = 'http://0.0.0.0:8000' + # REPO = 'https://raw.githubusercontent.com/radiantearth/stac-spec' + #TAG = 'v{}'.format(pystac.STAC_VERSION) + TAG = 'v0.8.0-rc1' + + schemas = { + Catalog: 'catalog-spec/json-schema/catalog.json', + Collection: 'collection-spec/json-schema/collection.json', + Item: 'item-spec/json-schema/item.json', + LabelItem: 'extensions/label/schema.json', + } + + for c in schemas: + # schemas[c] = '{}/{}/{}'.format(REPO, TAG, schemas[c]) + schemas[c] = '{}/{}'.format(REPO, schemas[c]) + + def __init__(self): + self.schema_cache = {} + + def get_schema(self, obj): + schema_uri = SchemaValidator.schemas.get(type(obj)) + + if schema_uri is None: + raise Exception('No schema for type {}'.format(type(obj))) + schema = self.schema_cache.get(obj) + if schema is None: + schema = json.loads(STAC_IO.read_text(schema_uri)) + self.schema_cache[type(obj)] = schema + + resolver = RefResolver(base_uri=schema_uri, + referrer=schema) + + return (schema, resolver) + + def validate(self, obj): + schema, resolver = self.get_schema(obj) + seralized = obj.to_dict() + + try: + jsonschema.validate(instance=seralized, schema=schema, resolver=resolver) + except jsonschema.exceptions.ValidationError as e: + print('Validation error in {}'.format(obj)) + raise e