Skip to content

Commit 5b6622c

Browse files
jpolchlogadomski
andauthored
Let non-hierarchical links to hierachical elements be relative paths for self-contained catalogs (#1169)
* deps: ceil vcrpy * First attempt improving the link serialization for non-standard link types * Update test * Small refactor * Fixup formatting error * Add test to demonstrate proper function of non-hierarchical relative links * Make hierarchy search more efficient * Improve test coverage * Use cassette for test * Update pystac/stac_object.py * Update pystac/stac_object.py --------- Co-authored-by: Pete Gadomski <[email protected]>
1 parent ba74fd5 commit 5b6622c

File tree

5 files changed

+203
-7
lines changed

5 files changed

+203
-7
lines changed

pystac/link.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,11 @@ def get_href(self, transform_href: bool = True) -> Optional[str]:
183183
*pystac.EXTENSION_HOOKS.get_extended_object_links(self.owner),
184184
]
185185
# if a hierarchical link with an owner and root, and relative catalog
186-
if root and root.is_relative() and self.rel in rel_links:
187-
owner_href = self.owner.get_self_href()
188-
if owner_href is not None:
189-
href = make_relative_href(href, owner_href)
186+
if root and root.is_relative():
187+
if self.rel in rel_links or root.target_in_hierarchy(self.target):
188+
owner_href = self.owner.get_self_href()
189+
if owner_href is not None:
190+
href = make_relative_href(href, owner_href)
190191

191192
return href
192193

pystac/stac_object.py

+38
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
Iterable,
1111
List,
1212
Optional,
13+
Set,
1314
Type,
1415
TypeVar,
1516
Union,
@@ -136,6 +137,43 @@ def remove_hierarchical_links(self, add_canonical: bool = False) -> List[Link]:
136137
self.links = keep
137138
return remove
138139

140+
def target_in_hierarchy(self, target: Union[str, STACObject]) -> bool:
141+
"""Determine if target is somewhere in the hierarchical link tree of
142+
a STACObject.
143+
144+
Args:
145+
target: A string or STACObject to search for
146+
147+
Returns:
148+
bool: Returns True if the target was found in the hierarchical link tree
149+
for the current STACObject
150+
"""
151+
152+
def traverse(
153+
obj: Union[str, STACObject], visited: Set[Union[str, STACObject]]
154+
) -> bool:
155+
if obj == target:
156+
return True
157+
if isinstance(obj, str):
158+
return False
159+
160+
new_targets = [
161+
link.target
162+
for link in obj.links
163+
if link.is_hierarchical() and link.target not in visited
164+
]
165+
if target in new_targets:
166+
return True
167+
168+
for subtree in new_targets:
169+
visited.add(subtree)
170+
if traverse(subtree, visited):
171+
return True
172+
173+
return False
174+
175+
return traverse(self, set([self]))
176+
139177
def get_single_link(
140178
self,
141179
rel: Optional[Union[str, pystac.RelType]] = None,

pystac/validation/local_validator.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
ITEM_SCHEMA_URI = (
1717
f"https://schemas.stacspec.org/v{VERSION}/item-spec/json-schema/item.json"
1818
)
19-
COLLECTION_SCHEMA_URI = f"https://schemas.stacspec.org/v{VERSION}/collection-spec/json-schema/collection.json"
19+
COLLECTION_SCHEMA_URI = (
20+
f"https://schemas.stacspec.org/v{VERSION}/"
21+
"collection-spec/json-schema/collection.json"
22+
)
2023
CATALOG_SCHEMA_URI = (
2124
f"https://schemas.stacspec.org/v{VERSION}/catalog-spec/json-schema/catalog.json"
2225
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
interactions:
2+
- request:
3+
body: null
4+
headers:
5+
Connection:
6+
- close
7+
Host:
8+
- raw.githubusercontent.com
9+
User-Agent:
10+
- Python-urllib/3.8
11+
method: GET
12+
uri: https://raw.githubusercontent.com/radiantearth/stac-spec/v0.8.1/collection-spec/examples/sentinel2.json
13+
response:
14+
body:
15+
string: "{\n \"stac_version\": \"0.8.1\",\n \"stac_extensions\": [],\n \"id\":
16+
\"COPERNICUS/S2\",\n \"title\": \"Sentinel-2 MSI: MultiSpectral Instrument,
17+
Level-1C\",\n \"description\": \"Sentinel-2 is a wide-swath, high-resolution,
18+
multi-spectral\\nimaging mission supporting Copernicus Land Monitoring studies,\\nincluding
19+
the monitoring of vegetation, soil and water cover,\\nas well as observation
20+
of inland waterways and coastal areas.\\n\\nThe Sentinel-2 data contain 13
21+
UINT16 spectral bands representing\\nTOA reflectance scaled by 10000. See
22+
the [Sentinel-2 User Handbook](https://sentinel.esa.int/documents/247904/685211/Sentinel-2_User_Handbook)\\nfor
23+
details. In addition, three QA bands are present where one\\n(QA60) is a bitmask
24+
band with cloud mask information. For more\\ndetails, [see the full explanation
25+
of how cloud masks are computed.](https://sentinel.esa.int/web/sentinel/technical-guides/sentinel-2-msi/level-1c/cloud-masks)\\n\\nEach
26+
Sentinel-2 product (zip archive) may contain multiple\\ngranules. Each granule
27+
becomes a separate Earth Engine asset.\\nEE asset ids for Sentinel-2 assets
28+
have the following format:\\nCOPERNICUS/S2/20151128T002653_20151128T102149_T56MNN.
29+
Here the\\nfirst numeric part represents the sensing date and time, the\\nsecond
30+
numeric part represents the product generation date and\\ntime, and the final
31+
6-character string is a unique granule identifier\\nindicating its UTM grid
32+
reference (see [MGRS](https://en.wikipedia.org/wiki/Military_Grid_Reference_System)).\\n\\nFor
33+
more details on Sentinel-2 radiometric resoltuon, [see this page](https://earth.esa.int/web/sentinel/user-guides/sentinel-2-msi/resolutions/radiometric).\\n\",\n
34+
\ \"license\": \"proprietary\",\n \"keywords\": [\n \"copernicus\",\n
35+
\ \"esa\",\n \"eu\",\n \"msi\",\n \"radiance\",\n \"sentinel\"\n
36+
\ ],\n \"providers\": [\n {\n \"name\": \"European Union/ESA/Copernicus\",\n
37+
\ \"roles\": [\n \"producer\",\n \"licensor\"\n ],\n
38+
\ \"url\": \"https://sentinel.esa.int/web/sentinel/user-guides/sentinel-2-msi\"\n
39+
\ }\n ],\n \"extent\": {\n \"spatial\": {\n \"bbox\": [\n [\n
40+
\ -180,\n -56,\n 180,\n 83\n ]\n
41+
\ ]\n },\n \"temporal\": {\n \"interval\": [\n [\n \"2015-06-23T00:00:00Z\",\n
42+
\ null\n ]\n ]\n }\n },\n\n \"summaries\": {\n \"datetime\":
43+
\ {\n \"min\": \"2015-06-23T00:00:00Z\",\n \"max\": \"2019-07-10T13:44:56Z\"\n
44+
\ },\n \"sci:citation\": [\"Copernicus Sentinel data [Year]\"],\n \"eo:gsd\":
45+
[10,30,60],\n \"eo:platform\": [\"sentinel-2a\",\"sentinel-2b\"],\n \"eo:constellation\":
46+
[\"sentinel-2\"],\n \"eo:instrument\": [\"msi\"],\n \"eo:off_nadir\":
47+
{\n \"min\": 0.0,\n \"max\": 100\n },\n \"eo:sun_elevation\":
48+
{\n \"min\": 6.78,\n \"max\": 89.9\n },\n \"eo:epsg\": [32601,32602,32603,32604,32605,32606,32607,32608,32609,32610,32611,32612,32613,32614,32615,32616,32617,32618,32619,32620,32621,32622,32623,32624,32625,32626,32627,32628,32629,32630,32631,32632,32633,32634,32635,32636,32637,32638,32639,32640,32641,32642,32643,32644,32645,32646,32647,32648,32649,32650,32651,32652,32653,32654,32655,32656,32657,32658,32659,32660],\n
49+
\ \"eo:bands\": [\n [\n {\n \"name\": \"B1\",\n \"common_name\":
50+
\"coastal\",\n \"center_wavelength\": 4.439,\n \"gsd\":
51+
60\n },\n {\n \"name\": \"B2\",\n \"common_name\":
52+
\"blue\",\n \"center_wavelength\": 4.966,\n \"gsd\": 10\n
53+
\ },\n {\n \"name\": \"B3\",\n \"common_name\":
54+
\"green\",\n \"center_wavelength\": 5.6,\n \"gsd\": 10\n
55+
\ },\n {\n \"name\": \"B4\",\n \"common_name\":
56+
\"red\",\n \"center_wavelength\": 6.645,\n \"gsd\": 10\n
57+
\ },\n {\n \"name\": \"B5\",\n \"center_wavelength\":
58+
7.039,\n \"gsd\": 20\n },\n {\n \"name\":
59+
\"B6\",\n \"center_wavelength\": 7.402,\n \"gsd\": 20\n
60+
\ },\n {\n \"name\": \"B7\",\n \"center_wavelength\":
61+
7.825,\n \"gsd\": 20\n },\n {\n \"name\":
62+
\"B8\",\n \"common_name\": \"nir\",\n \"center_wavelength\":
63+
8.351,\n \"gsd\": 10\n },\n {\n \"name\":
64+
\"B8A\",\n \"center_wavelength\": 8.648,\n \"gsd\": 20\n
65+
\ },\n {\n \"name\": \"B9\",\n \"center_wavelength\":
66+
9.45,\n \"gsd\": 60\n },\n {\n \"name\": \"B10\",\n
67+
\ \"center_wavelength\": 1.3735,\n \"gsd\": 60\n },\n
68+
\ {\n \"name\": \"B11\",\n \"common_name\": \"swir16\",\n
69+
\ \"center_wavelength\": 1.6137,\n \"gsd\": 20\n },\n
70+
\ {\n \"name\": \"B12\",\n \"common_name\": \"swir22\",\n
71+
\ \"center_wavelength\": 2.2024,\n \"gsd\": 20\n }\n
72+
\ ]\n ]\n },\n \"links\": [\n {\n \"rel\": \"self\",\n \"href\":
73+
\"https://storage.cloud.google.com/earthengine-test/catalog/COPERNICUS_S2.json\"\n
74+
\ },\n {\n \"rel\": \"parent\",\n \"href\": \"https://storage.cloud.google.com/earthengine-test/catalog/catalog.json\"\n
75+
\ },\n {\n \"rel\": \"root\",\n \"href\": \"https://storage.cloud.google.com/earthengine-test/catalog/catalog.json\"\n
76+
\ },\n {\n \"rel\": \"license\",\n \"href\": \"https://scihub.copernicus.eu/twiki/pub/SciHubWebPortal/TermsConditions/Sentinel_Data_Terms_and_Conditions.pdf\",\n
77+
\ \"title\": \"Legal notice on the use of Copernicus Sentinel Data and
78+
Service Information\"\n }\n ]\n}"
79+
headers:
80+
Accept-Ranges:
81+
- bytes
82+
Access-Control-Allow-Origin:
83+
- '*'
84+
Cache-Control:
85+
- max-age=300
86+
Connection:
87+
- close
88+
Content-Length:
89+
- '5364'
90+
Content-Security-Policy:
91+
- default-src 'none'; style-src 'unsafe-inline'; sandbox
92+
Content-Type:
93+
- text/plain; charset=utf-8
94+
Cross-Origin-Resource-Policy:
95+
- cross-origin
96+
Date:
97+
- Tue, 27 Jun 2023 14:42:50 GMT
98+
ETag:
99+
- '"7b5b9590049813a43b1a9c064eb61dd6b9c25e8e649fff820d3ac83580b7e559"'
100+
Expires:
101+
- Tue, 27 Jun 2023 14:47:50 GMT
102+
Source-Age:
103+
- '0'
104+
Strict-Transport-Security:
105+
- max-age=31536000
106+
Vary:
107+
- Authorization,Accept-Encoding,Origin
108+
Via:
109+
- 1.1 varnish
110+
X-Cache:
111+
- MISS
112+
X-Cache-Hits:
113+
- '0'
114+
X-Content-Type-Options:
115+
- nosniff
116+
X-Fastly-Request-ID:
117+
- e6f3b9fe41946ac3e378d2af0fb3a39aa86ec656
118+
X-Frame-Options:
119+
- deny
120+
X-GitHub-Request-Id:
121+
- A0E8:5E35:4235C:4D583:649AF569
122+
X-Served-By:
123+
- cache-ewr18137-EWR
124+
X-Timer:
125+
- S1687876971.626913,VS0,VE91
126+
X-XSS-Protection:
127+
- 1; mode=block
128+
status:
129+
code: 200
130+
message: OK
131+
version: 1

tests/test_item.py

+25-2
Original file line numberDiff line numberDiff line change
@@ -514,8 +514,8 @@ def test_add_derived_from(test_case_1_catalog: Catalog) -> None:
514514
link for link in item_0.links if link.rel == pystac.RelType.DERIVED_FROM
515515
]
516516
assert len(filtered) == 2
517-
assert filtered[0].to_dict()["href"] == item_1.self_href
518-
assert filtered[1].to_dict()["href"] == item_2.self_href
517+
assert filtered[0].to_dict(transform_href=False)["href"] == item_1.self_href
518+
assert filtered[1].to_dict(transform_href=False)["href"] == item_2.self_href
519519

520520

521521
def test_get_unresolvable_derived_from(test_case_1_catalog: Catalog) -> None:
@@ -607,3 +607,26 @@ def test_resolve_collection_with_root(
607607
root = read_collection.get_root()
608608
assert root
609609
assert root.id == "root"
610+
611+
612+
@pytest.mark.vcr()
613+
def test_non_hierarchical_relative_link() -> None:
614+
root = pystac.Catalog("root", "root")
615+
a = pystac.Catalog("a", "a")
616+
b = pystac.Catalog("b", "b")
617+
618+
root.add_child(a)
619+
root.add_child(b)
620+
a.add_link(pystac.Link("related", b))
621+
b.add_link(
622+
pystac.Link("item", TestCases.get_path("data-files/item/sample-item.json"))
623+
)
624+
625+
root.catalog_type = pystac.catalog.CatalogType.SELF_CONTAINED
626+
root.normalize_hrefs("test_output")
627+
related_href = [link for link in a.links if link.rel == "related"][0].get_href()
628+
629+
assert related_href is not None and not is_absolute_href(related_href)
630+
assert a.target_in_hierarchy(b)
631+
assert root.target_in_hierarchy(next(b.get_items()))
632+
assert root.target_in_hierarchy(root)

0 commit comments

Comments
 (0)