Skip to content

Commit 41c0b78

Browse files
authored
Make signing STAC objects more convenient (#12)
* Make sign work with URL, Asset, Item, or ItemCollection Also added a search_and_sign function that takes an ItemSearch, performs the search, and signs the resulting ItemCollection * Rename test_sign_assets file to better reflect its purpose * Update README * Update test class name * Switch to using singledispatch for sign functions * Add missing docstring
1 parent a543c7b commit 41c0b78

5 files changed

Lines changed: 153 additions & 54 deletions

File tree

README.md

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -42,28 +42,40 @@ pip install -e .
4242

4343
## Usage
4444

45-
This library currently assists with signing Azure Blob Storage URLs, both within PySTAC assets, and by providing raw URLs. The following examples demonstrate both of these use cases:
45+
This library currently assists with signing Azure Blob Storage URLs. The `sign` function operates directly on an HREF string, as well as several [PySTAC](https://github.com/stac-utils/pystac) objects: `Asset`, `Item`, and `ItemCollection`. In addition, the `sign` function accepts a [STAC API Client](https://github.com/stac-utils/pystac-client) `ItemSearch`, which performs a search and returns the resulting `ItemCollection` with all assets signed. The following example demonstrates these use cases:
4646

4747
```python
48-
import pystac
48+
from pystac import Asset, Item
49+
from pystac_client import ItemCollection, ItemSearch
4950
import planetary_computer as pc
5051

51-
raw_item: pystac.Item = ...
52-
item: pystac.Item = pc.sign_assets(raw_item)
5352

53+
# The sign function may be called directly on the Item
54+
raw_item: Item = ...
55+
item: Item = pc.sign(raw_item)
5456
# Now use the item however you want. All appropriate assets are signed for read access.
55-
```
56-
57-
```python
58-
import planetary_computer as pc
59-
import pystac
60-
61-
item: pystac.Item = ... # Landsat item
62-
63-
b4_href = pc.sign(item.assets['SR_B4'].href)
6457

65-
with rasterio.open(b4_href) as ds:
66-
...
58+
# The sign function also works with an Asset
59+
raw_asset: Asset = raw_item.assets['SR_B4']
60+
asset = pc.sign(raw_asset)
61+
62+
# The sign function also works with an HREF
63+
raw_href: str = raw_asset.href
64+
href = pc.sign(raw_href)
65+
66+
# The sign function also works with an ItemCollection
67+
raw_item_collection = ItemCollection([raw_item])
68+
item_collection = pc.sign(raw_item_collection)
69+
70+
# The sign function also accepts an ItemSearch, and signs the resulting ItemCollection
71+
search = ItemSearch(
72+
url=...,
73+
bbox=...,
74+
collections=...,
75+
limit=...,
76+
max_items=...,
77+
)
78+
signed_item_collection = pc.sign(search)
6779
```
6880

6981

planetary_computer/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""Planetary Computer Python SDK"""
22
# flake8:noqa
33

4-
from planetary_computer.sas import sign, sign_assets # type:ignore
4+
from planetary_computer.sas import sign # type:ignore
55
from planetary_computer.settings import set_subscription_key # type:ignore

planetary_computer/sas.py

Lines changed: 80 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
from datetime import datetime, timezone
2-
from typing import Dict
2+
from typing import Any, Dict
33

4+
from functools import singledispatch
5+
import requests
46
from pydantic import BaseModel, Field
5-
import pystac
7+
from pystac import Asset, Item
68
from pystac.utils import datetime_to_str
7-
import requests
9+
from pystac_client import ItemCollection, ItemSearch
810

9-
10-
from planetary_computer.utils import parse_blob_url
1111
from planetary_computer.settings import Settings
12+
from planetary_computer.utils import parse_blob_url
1213

1314

1415
class SASBase(BaseModel):
@@ -50,24 +51,24 @@ def ttl(self) -> float:
5051
TOKEN_CACHE: Dict[str, SASToken] = {}
5152

5253

53-
def sign(url: str) -> str:
54-
"""Sign a URL with a Shared Access (SAS)
55-
Token, which allows for read access.
56-
57-
Parameters
58-
----------
59-
url (str): The HREF of the asset in the format of a URL.
60-
This can be found on STAC Item's Asset 'href' value.
54+
@singledispatch
55+
def sign(obj: Any) -> Any:
56+
"""Sign the relevant URLs belonging to any supported object with a
57+
Shared Access (SAS) Token, which allows for read access.
6158
62-
Returns
63-
-------
64-
The signed HREF that permits read access to the asset.
59+
Args:
60+
obj (Any): The object to sign. Must be one of:
61+
str (URL), Asset, Item, ItemCollection, or ItemSearch
62+
Returns:
63+
Any: A copy of the object where all relevant URLs have been signed
6564
"""
66-
link = sign_link(url)
67-
return link.href
65+
raise TypeError(
66+
"Invalid type, must be one of: str, Asset, Item, ItemCollection, or ItemSearch"
67+
)
6868

6969

70-
def sign_link(url: str) -> SignedLink:
70+
@sign.register(str)
71+
def _sign_url(url: str) -> str:
7172
"""Sign a URL with a Shared Access (SAS) Token, which allows for read access.
7273
7374
Args:
@@ -76,9 +77,7 @@ def sign_link(url: str) -> SignedLink:
7677
value.
7778
7879
Returns:
79-
SignedLink: An object that contains the signed HREF
80-
in the format of a URL and the expiry time, which
81-
is when the HREF will no longer permit read access.
80+
str: The signed HREF
8281
"""
8382
settings = Settings.get()
8483
account, container = parse_blob_url(url)
@@ -99,22 +98,76 @@ def sign_link(url: str) -> SignedLink:
9998
if not token:
10099
raise ValueError(f"No token found in response: {response.json()}")
101100
TOKEN_CACHE[token_request_url] = token
102-
return token.sign(url)
101+
return token.sign(url).href
103102

104103

105-
def sign_assets(item: pystac.Item) -> pystac.Item:
104+
@sign.register(Item)
105+
def _sign_item(item: Item) -> Item:
106106
"""Sign all assets within a PySTAC item
107107
108108
Args:
109-
item (pystac.Item): The Item whose assets that will be signed
109+
item (Item): The Item whose assets that will be signed
110110
111111
Returns:
112-
pystac.Item: A new copy of the Item where all assets HREFs have
112+
Item: A new copy of the Item where all assets' HREFs have
113113
been replaced with a signed version. In addition, a "msft:expiry"
114114
property is added to the Item properties indicating the earliest
115115
expiry time for any assets that were signed.
116116
"""
117117
signed_item = item.clone()
118118
for key in signed_item.assets:
119-
signed_item.assets[key].href = sign(signed_item.assets[key].href)
119+
signed_item.assets[key] = sign(signed_item.assets[key])
120120
return signed_item
121+
122+
123+
@sign.register(Asset)
124+
def _sign_asset(asset: Asset) -> Asset:
125+
"""Sign a PySTAC asset
126+
127+
Args:
128+
asset (Asset): The Asset to sign
129+
130+
Returns:
131+
Asset: A new copy of the Asset where the HREF is replaced with a
132+
signed version.
133+
"""
134+
signed_asset = asset.clone()
135+
signed_asset.href = sign(signed_asset.href)
136+
return signed_asset
137+
138+
139+
@sign.register(ItemCollection)
140+
def _sign_item_collection(item_collection: ItemCollection) -> ItemCollection:
141+
"""Sign a PySTAC item collection
142+
143+
Args:
144+
item_collection (ItemCollection): The ItemCollection whose assets will be signed
145+
146+
Returns:
147+
ItemCollection: A new copy of the ItemCollection where all assets'
148+
HREFs for each item have been replaced with a signed version. In addition,
149+
a "msft:expiry" property is added to the Item properties indicating the
150+
earliest expiry time for any assets that were signed.
151+
"""
152+
return ItemCollection.from_dict(
153+
{
154+
"type": "FeatureCollection",
155+
"features": [sign(item).to_dict() for item in item_collection],
156+
}
157+
)
158+
159+
160+
@sign.register(ItemSearch)
161+
def _search_and_sign(search: ItemSearch) -> ItemCollection:
162+
"""Perform a PySTAC Client search, and sign the resulting item collection
163+
164+
Args:
165+
search (ItemSearch): The ItemSearch whose resulting item assets will be signed
166+
167+
Returns:
168+
ItemCollection: The resulting ItemCollection of the search where all assets'
169+
HREFs for each item have been replaced with a signed version. In addition,
170+
a "msft:expiry" property is added to the Item properties indicating the
171+
earliest expiry time for any assets that were signed.
172+
"""
173+
return sign(search.items_as_collection())

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ install_requires =
1414
click>=7.1
1515
pydantic[dotenv]>=1.7.3
1616
pystac>=0.5.6,<0.6
17+
pystac-client>=0.1.1,<0.2.0
1718
pytz>=2020.5
1819
requests>=2.25.1
1920

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import os
22
import json
3+
from typing import Any, Dict
34
import unittest
45
from urllib.parse import parse_qs, urlparse
6+
import warnings
57

68
import requests
79

810
import planetary_computer as pc
911
from planetary_computer.utils import parse_blob_url
10-
import pystac
12+
from pystac import Item
13+
from pystac_client import ItemCollection, ItemSearch
1114

1215

1316
ACCOUNT_NAME = "naipeuwest"
@@ -23,17 +26,26 @@
2326
"/GRANULE/L2A_T10TET_A018672_20201002T192031/QI_DATA/T10TET_20201002T191229_PVI.tif"
2427
)
2528

29+
PC_SEARCH_URL = "https://planetarycomputer.microsoft.com/api/stac/v1/search"
2630

27-
def get_sample_item() -> pystac.Item:
31+
32+
def get_sample_item_dict() -> Dict[str, Any]:
2833
file_path = os.path.abspath(
2934
os.path.join(os.path.dirname(__file__), "data-files/sample-item.json")
3035
)
3136
with open(file_path) as json_file:
32-
item_dict = json.load(json_file)
33-
return pystac.Item.from_dict(item_dict)
37+
return json.load(json_file)
38+
39+
40+
def get_sample_item() -> Item:
41+
return Item.from_dict(get_sample_item_dict())
42+
43+
44+
def get_sample_item_collection() -> ItemCollection:
45+
return ItemCollection([get_sample_item()])
3446

3547

36-
class TestSignAssests(unittest.TestCase):
48+
class TestSigning(unittest.TestCase):
3749
def assertSigned(self, url: str) -> None:
3850
# Ensure the signed item has an "se" URL parameter added to it,
3951
# which indicates it has been signed
@@ -57,17 +69,38 @@ def test_unsigned_assets(self) -> None:
5769
self.assertEqual(EXP_METADATA, item.assets["metadata"].href)
5870
self.assertEqual(EXP_THUMBNAIL, item.assets["thumbnail"].href)
5971

60-
def test_signed_assets(self) -> None:
61-
unsigned_item = get_sample_item()
62-
signed_item = pc.sign_assets(unsigned_item)
63-
64-
# Ensure the original item wasn't mutated, and all URLs are signed
72+
def verify_signed_urls_in_item(self, signed_item: Item) -> None:
6573
for key in ["image", "metadata", "thumbnail"]:
6674
signed_url = signed_item.assets[key].href
67-
self.assertNotEqual(unsigned_item.assets[key].href, signed_url)
6875
self.assertSigned(signed_url)
6976

77+
def test_signed_assets(self) -> None:
78+
signed_item = pc.sign(get_sample_item())
79+
self.verify_signed_urls_in_item(signed_item)
80+
7081
def test_read_signed_asset(self) -> None:
7182
signed_href = pc.sign(SENTINEL_THUMBNAIL)
7283
r = requests.get(signed_href)
7384
self.assertEqual(r.status_code, 200)
85+
86+
def test_signed_item_collection(self) -> None:
87+
signed_item_collection = pc.sign(get_sample_item_collection())
88+
self.assertEqual(len(list(signed_item_collection)), 1)
89+
for signed_item in signed_item_collection:
90+
self.verify_signed_urls_in_item(signed_item)
91+
92+
def test_search_and_sign(self) -> None:
93+
# Filter out a resource warning coming from within the pystac-client search
94+
warnings.simplefilter("ignore", ResourceWarning)
95+
96+
search = ItemSearch(
97+
url=PC_SEARCH_URL,
98+
bbox=(-73.21, 43.99, -73.12, 44.05),
99+
collections=CONTAINER_NAME,
100+
limit=1,
101+
max_items=1,
102+
)
103+
signed_item_collection = pc.sign(search)
104+
self.assertEqual(len(list(signed_item_collection)), 1)
105+
for signed_item in signed_item_collection:
106+
self.verify_signed_urls_in_item(signed_item)

0 commit comments

Comments
 (0)