Adding New and Custom Extensions

This tutorial will cover how to add new extensions to PySTAC. It will go over how to contribute a common extension (one found in the stac-spec repo), as well as how to register a custom extension with PySTAC.

We’ll work on implementing the Satellite Extension with a modified extension ID, registering it as space_camera instead of sat.

[1]:
import pystac

If we were implementing the sat extension for real, we would make sure there is an entry for our extension in the pystac.extensions.Extensions object found here with the relevant entry. Here we’ll just use our own Extensions class to define our fake SPACE_CAMERA extension ID:

[2]:
class Extensions:
    SPACE_CAMERA = 'space_camera'

For this tutorial we’ll use some code below to read in an item and modify the real sat extension ID into our tutorial space_camera ID. If we didn’t need to do this modification, we could simply read in the item from the URI using pystac.read_file.

[3]:
import json

def modify_sat_extension_id(item_json):
    item_json['stac_extensions'].remove(pystac.extensions.Extensions.SAT)
    item_json['stac_extensions'].append(Extensions.SPACE_CAMERA)

def read_item(href):
    item_json = json.loads(pystac.STAC_IO.read_text(href))
    modify_sat_extension_id(item_json)
    return pystac.read_dict(item_json)

Here we read in an item that implements the sat extension, which based on the above code will modify to implement the space_camera extension:

[4]:
item_before = read_item('https://raw.githubusercontent.com/radiantearth/stac-spec/v1.0.0-beta.2/extensions/sat/examples/example-landsat8.json')

[5]:
item_before.ext.implements(Extensions.SPACE_CAMERA)
[5]:
True

Even though the item reports it implements that extension, that extension isn’t registered with PySTAC and if we try to access the extension functionality it will tell us so:

[6]:
item_before.ext[Extensions.SPACE_CAMERA]
---------------------------------------------------------------------------
ExtensionError                            Traceback (most recent call last)
<ipython-input-6-538406720a4a> in <module>
----> 1 item_before.ext[Extensions.SPACE_CAMERA]

~/proj/stac/pystac/venv/lib/python3.6/site-packages/pystac-0.5.0-py3.6.egg/pystac/stac_object.py in __getitem__(self, extension_id)
     39         if not pystac.STAC_EXTENSIONS.is_registered_extension(extension_id):
     40             raise ExtensionError("'{}' is not an extension "
---> 41                                  "registered with PySTAC".format(extension_id))
     42
     43         if not self.implements(extension_id):

ExtensionError: 'space_camera' is not an extension registered with PySTAC

So let’s implement it!

Implementing an ItemExtension

We’ll be referring to the Satellite Extensions Specification (referred to as the spec) to implement this extension.

The sat extension (or in our case space_camera extension) is scoped to Item. That information is found in the “Scope” line at the top of the spec. We’ll want to implement an CatalogExtension, CollectionExtension, and ItemExtension for each of the STAC object types in the scope. In this case, we’re only implementing an ItemExtension.

[7]:
from pystac.extensions.base import ItemExtension

To implement the object extension, create a child class that implements each of the abstract methods of the relevant base class. For ItemExtension, the only required methods are listed below, along with an appropriate __init__ method:

[8]:
class SatItemExt(ItemExtension):
    def __init__(self, item):
        self.item = item

    @classmethod
    def from_item(self, item):
        return SatItemExt(item)

    @classmethod
    def _object_links(cls):
        return []

The from_item class method simply returns a new instance of the item extension given an item.

The _object_links class method returns the rel string for any links that point to STAC objects like Catalogs, Collections or Items. PySTAC needs to know which links point to STAC objects because it needs to consider them when fully resolving a STAC into in-memory objects. It also will use this information when deciding on whether to use absolute or relative HREFs for the links, based on the root catalog type. In a lot of cases, extensions don’t add new links to STAC objects, so this is normally an empty list; however, if the extension does do this (like the source link in the Label Extension), make sure to return the correct value (like the LabelItemExt is doing here).

Defining properties

An extension object works by modifying the Item (or whichever STAC object is being extended) directly through Python property getters and setters. The getter should read directly from the properties or extra_fields in the item and perform any trasformations needed to convert to the relevant Python objects (e.g. transform a string into a datetime object). Likewise, the setter should take in Python objects and transform them to their serialized string, and set them in the appropriate place in item. This way the extension modifies the Item directly, and will not require any specialized serialization or deserialization logic. This also allows multiple extensions to be used to access and set information on the STAC object - a distinct advantage to the inheritance-based extension implementation that PySTAC used before 0.4.0.

For the sat extension we have two properties to implement, both of which are straightforward and do not need any transformation in the getters and setters:

[9]:
class SatItemExt(ItemExtension):
    def __init__(self, item):
        self.item = item

    @property
    def orbit_state(self):
        """"ADD DOCSTRING!"""
        return self.item.properties.get('sat:orbit_state')

    @orbit_state.setter
    def orbit_state(self, v):
        self.item.properties['sat:orbit_state'] = v

    @property
    def relative_orbit(self):
        """"ADD DOCSTRING!"""
        return self.item.properties.get('sat:relative_orbit')

    @relative_orbit.setter
    def relative_orbit(self, v):
        self.item.properties['sat:relative_orbit'] = v

    @classmethod
    def from_item(self, item):
        return SatItemExt(item)

    @classmethod
    def _object_links(cls):
        return []

Extensions also define an apply method that encodes the optional and required values that go into the extension. The apply should list all of the values of the extension, and give default values of None to optional parameters. That way a users adding an extension to an object can easily tell what values are needed to implement the extension.

Here we use the apply method to encode the requirement in the spec that at least one of orbit_state and relative_orbit need to be defined:

[10]:
class SatItemExt(ItemExtension):
    def __init__(self, item):
        self.item = item

    def apply(self, orbit_state=None, relative_orbit=None):
        """Applies Satellite extension properties to the extended Item.

        Args:
            orbit_state (str): The state of the orbit. Either ascending or descending
                for polar orbiting satellites, or geostationary for geosynchronous satellites
            relative_orbit (int): The relative orbit number at the time of acquisition.

        Note:
            At least one property must be supplied.
        """
        if orbit_state is None and relative_orbit is None:
            raise pystac.STACError("sat extension needs at least one property value.")

        self.orbit_state = orbit_state
        self.relative_orbit = relative_orbit

    @property
    def orbit_state(self):
        return self.item.properties.get('sat:orbit_state')

    @orbit_state.setter
    def orbit_state(self, v):
        self.item.properties['sat:orbit_state'] = v

    @property
    def relative_orbit(self):
        return self.item.properties.get('sat:relative_orbit')

    @relative_orbit.setter
    def relative_orbit(self, v):
        self.item.properties['sat:relative_orbit'] = v

    @classmethod
    def from_item(self, item):
        return SatItemExt(item)

    @classmethod
    def _object_links(cls):
        return []

Now that we have our object extension we need to register it with PySTAC. To do so we’ll need to define an ExtendedObject to tie together the PySTAC object we are extending and our SatItemExt class:

[11]:
from pystac.extensions.base import ExtendedObject
[12]:
extended_object = ExtendedObject(pystac.Item, SatItemExt)

Then we define an ExtensionDefinition that ties together our extension ID with the list of object extensions. In this case, we are only extending Item and so there’s only a single entry in the list:

[13]:
from pystac.extensions.base import ExtensionDefinition
[14]:
extension_definition = ExtensionDefinition(Extensions.SPACE_CAMERA, [extended_object])

For common extensions this definition usually happens at the end of the extension file all in one line; see this example.

Now we can register the extension definition with PySTAC. For common extensions defined in the library you would add it to the list in the top level package **init**. However if you’re creating a custom extension you can use the following method:

[15]:
pystac.STAC_EXTENSIONS.add_extension(extension_definition)

Remember, if you are implementing an extension in PySTAC, make sure to add thorough unit tests (example) and add the extension to the documentation (example)!

Using the extension

When we read the item (again manipulating the JSON so that the sat extension ID turns into space_camera), we can now access the extension functionality through the same means as the other extensions:

[16]:
item_after = read_item('https://raw.githubusercontent.com/radiantearth/stac-spec/v1.0.0-beta.2/extensions/sat/examples/example-landsat8.json')
[17]:
item_after.ext[Extensions.SPACE_CAMERA].orbit_state
[17]:
'ascending'
[18]:
item_after.ext.space_camera.relative_orbit = 5

Notice that setting the property value through the extension sets the correct item property:

[19]:
item_after.properties['sat:relative_orbit']
[19]:
5

We can also read in an item that does not already implement the extension, enable it, and use the apply method to fill out the values:

[20]:
item3 = pystac.read_file('https://raw.githubusercontent.com/radiantearth/stac-spec/v1.0.0-beta.2/item-spec/examples/sample-full.json')
[21]:
item3.ext.enable(Extensions.SPACE_CAMERA)
[22]:
help(item3.ext.space_camera.apply)
Help on method apply in module __main__:

apply(orbit_state=None, relative_orbit=None) method of __main__.SatItemExt instance
    Applies Satellite extension properties to the extended Item.

    Args:
        orbit_state (str): The state of the orbit. Either ascending or descending
            for polar orbiting satellites, or geostationary for geosynchronous satellites
        relative_orbit (int): The relative orbit number at the time of acquisition.

    Note:
        At least one property must be supplied.

[23]:
item3.ext.space_camera.apply(relative_orbit='ascending')
[ ]: