Skip to content

Commit 8d5b5cc

Browse files
check potential issue with change of collection body (#303)
* check potential issue with change of collection body * adapt tests * more tests * update * update docs * remove space * Update README.md * exclude hydrate markers * Refactor/pgstac version tests (#305) * update pypgstac version and tests multiple versions * use python 3.11 * revert --------- Co-authored-by: Pete Gadomski <pete.gadomski@gmail.com>
1 parent d3ef900 commit 8d5b5cc

File tree

9 files changed

+149
-34
lines changed

9 files changed

+149
-34
lines changed

.github/workflows/cicd.yaml

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,12 @@ on:
66
branches: [main]
77
workflow_dispatch:
88

9-
109
jobs:
1110
test:
1211
runs-on: ubuntu-latest
1312
strategy:
1413
matrix:
15-
include:
16-
- {python: '3.12', pypgstac: '0.9.*'}
17-
- {python: '3.12', pypgstac: '0.8.*'}
18-
- {python: '3.11', pypgstac: '0.8.*'}
19-
- {python: '3.10', pypgstac: '0.8.*'}
20-
- {python: '3.9', pypgstac: '0.8.*'}
14+
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
2115

2216
timeout-minutes: 20
2317

@@ -46,7 +40,6 @@ jobs:
4640
run: |
4741
python -m pip install --upgrade pip
4842
python -m pip install .[dev,server,validation]
49-
python -m pip install "pypgstac==${{ matrix.pypgstac }}"
5043
5144
- name: Run test suite
5245
run: python -m pytest --cov stac_fastapi.pgstac --cov-report xml --cov-report term-missing

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@ For more than millions of records it is recommended to either set a low connecti
4343

4444
### Hydration
4545

46-
To configure **stac-fastapi-pgstac** to [hydrate search result items in the API](https://stac-utils.github.io/pgstac/pgstac/#runtime-configurations), set the `USE_API_HYDRATE` environment variable to `true` or explicitly set the option in the PGStac Settings object.
46+
To configure **stac-fastapi-pgstac** to [hydrate search result items at the API level](https://stac-utils.github.io/pgstac/pgstac/#runtime-configurations), set the `USE_API_HYDRATE` environment variable to `true`. If `false` (default) the hydration will be done in the database.
47+
48+
| use_api_hydrate (API) | nohydrate (PgSTAC) | Hydration |
49+
| --- | --- | --- |
50+
| False | False | PgSTAC |
51+
| True | True | API |
4752

4853
### Migrations
4954

setup.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"buildpg",
1717
"brotli_asgi",
1818
"cql2>=0.3.6",
19-
"pypgstac>=0.8,<0.10",
19+
"pypgstac>=0.9,<0.10",
20+
"hydraters>=0.1.3",
2021
"typing_extensions>=4.9.0",
2122
"jsonpatch>=1.33.0",
2223
"json-merge-patch>=0.3.0",
@@ -25,7 +26,6 @@
2526
extra_reqs = {
2627
"dev": [
2728
"pystac[validation]",
28-
"pypgstac[psycopg]==0.9.*",
2929
"pytest-postgresql",
3030
"pytest",
3131
"pytest-cov",
@@ -36,6 +36,8 @@
3636
"httpx",
3737
"twine",
3838
"wheel",
39+
"psycopg[binary]==3.1.*",
40+
"psycopg-pool==3.1.*",
3941
],
4042
"docs": [
4143
"black>=23.10.1",
@@ -67,6 +69,8 @@
6769
"Programming Language :: Python :: 3.10",
6870
"Programming Language :: Python :: 3.11",
6971
"Programming Language :: Python :: 3.12",
72+
"Programming Language :: Python :: 3.13",
73+
"Programming Language :: Python :: 3.14",
7074
"License :: OSI Approved :: MIT License",
7175
],
7276
keywords="STAC FastAPI COG",

stac_fastapi/pgstac/config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,23 @@ class Settings(ApiSettings):
174174

175175
prefix_path: str = ""
176176
use_api_hydrate: bool = False
177+
"""
178+
When USE_API_HYDRATE=TRUE, PgSTAC database will receive `NO_HYDRATE=TRUE`
179+
180+
| use_api_hydrate | nohydrate | Hydration |
181+
| --- | --- | --- |
182+
| False | False | PgSTAC |
183+
| True | True | API |
184+
185+
ref: https://stac-utils.github.io/pgstac/pgstac/#runtime-configurations
186+
"""
187+
exclude_hydrate_markers: bool = True
188+
"""
189+
In some case, PgSTAC can return `DO_NOT_MERGE_MARKER` markers (`𒍟※`).
190+
If `EXCLUDE_HYDRATE_MARKERS=TRUE` and `USE_API_HYDRATE=TRUE`, stac-fastapi-pgstac
191+
will exclude those values from the responses.
192+
"""
193+
177194
invalid_id_chars: List[str] = DEFAULT_INVALID_ID_CHARS
178195
base_item_cache: Type[BaseItemCache] = DefaultBaseItemCache
179196

stac_fastapi/pgstac/core.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,11 @@ async def _get_base_item(collection_id: str) -> Dict[str, Any]:
331331
# Exclude None values
332332
base_item = {k: v for k, v in base_item.items() if v is not None}
333333

334-
feature = hydrate(base_item, feature)
334+
feature = hydrate(
335+
base_item,
336+
feature,
337+
strip_unmatched_markers=settings.exclude_hydrate_markers,
338+
)
335339

336340
# Grab ids needed for links that may be removed by the fields extension.
337341
collection_id = feature.get("collection")

tests/conftest.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import pytest
1010
from fastapi import APIRouter
1111
from httpx import ASGITransport, AsyncClient
12-
from pypgstac import __version__ as pgstac_version
1312
from pypgstac.db import PgstacDB
1413
from pypgstac.migrate import Migrate
1514
from pytest_postgresql.janitor import DatabaseJanitor
@@ -54,12 +53,6 @@
5453
logger = logging.getLogger(__name__)
5554

5655

57-
requires_pgstac_0_9_2 = pytest.mark.skipif(
58-
tuple(map(int, pgstac_version.split("."))) < (0, 9, 2),
59-
reason="PgSTAC>=0.9.2 required",
60-
)
61-
62-
6356
@pytest.fixture(scope="session")
6457
def database(postgresql_proc):
6558
with DatabaseJanitor(
@@ -79,7 +72,13 @@ def database(postgresql_proc):
7972
yield jan
8073

8174

82-
@pytest.fixture(autouse=True)
75+
@pytest.fixture(
76+
params=[
77+
"0.8.6",
78+
"0.9.8",
79+
],
80+
autouse=True,
81+
)
8382
async def pgstac(database):
8483
connection = f"postgresql://{database.user}:{quote(database.password)}@{database.host}:{database.port}/{database.dbname}"
8584
yield
@@ -100,7 +99,7 @@ async def pgstac(database):
10099
# Run all the tests that use the api_client in both db hydrate and api hydrate mode
101100
@pytest.fixture(
102101
params=[
103-
# hydratation, prefix, model_validation
102+
# API hydratation, prefix, model_validation
104103
(False, "", False),
105104
(False, "/router_prefix", False),
106105
(True, "", False),

tests/data/test2_item.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
"nodata": 0,
6565
"offset": 2.03976,
6666
"data_type": "uint8",
67-
"spatial_resolution": 60
67+
"spatial_resolution": 80
6868
}
6969
]
7070
},
@@ -172,7 +172,6 @@
172172
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
173173
"roles": ["cloud"],
174174
"title": "Pixel Quality Assessment Band (QA_PIXEL)",
175-
"description": "Collection 2 Level-1 Pixel Quality Assessment Band",
176175
"raster:bands": [
177176
{
178177
"unit": "bit index",

tests/resources/test_collection.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
import pytest
55
from stac_pydantic import Collection
66

7-
from ..conftest import requires_pgstac_0_9_2
8-
97

108
async def test_create_collection(app_client, load_test_data: Callable):
119
in_json = load_test_data("test_collection.json")
@@ -349,11 +347,15 @@ async def test_get_collections_search(
349347
assert len(resp.json()["collections"]) == 2
350348

351349

352-
@requires_pgstac_0_9_2
353350
@pytest.mark.asyncio
354351
async def test_collection_search_freetext(
355352
app_client, load_test_collection, load_test2_collection
356353
):
354+
res = await app_client.get("/_mgmt/health")
355+
pgstac_version = res.json()["pgstac"]["pgstac_version"]
356+
if tuple(map(int, pgstac_version.split("."))) < (0, 9, 2):
357+
pass
358+
357359
# free-text
358360
resp = await app_client.get(
359361
"/collections",
@@ -388,11 +390,15 @@ async def test_collection_search_freetext(
388390
assert len(resp.json()["collections"]) == 0
389391

390392

391-
@requires_pgstac_0_9_2
392393
@pytest.mark.asyncio
393394
async def test_collection_search_freetext_advanced(
394395
app_client_advanced_freetext, load_test_collection, load_test2_collection
395396
):
397+
res = await app_client_advanced_freetext.get("/_mgmt/health")
398+
pgstac_version = res.json()["pgstac"]["pgstac_version"]
399+
if tuple(map(int, pgstac_version.split("."))) < (0, 9, 2):
400+
pass
401+
396402
# free-text
397403
resp = await app_client_advanced_freetext.get(
398404
"/collections",
@@ -436,9 +442,13 @@ async def test_collection_search_freetext_advanced(
436442
assert len(resp.json()["collections"]) == 0
437443

438444

439-
@requires_pgstac_0_9_2
440445
@pytest.mark.asyncio
441446
async def test_all_collections_with_pagination(app_client, load_test_data):
447+
res = await app_client.get("/_mgmt/health")
448+
pgstac_version = res.json()["pgstac"]["pgstac_version"]
449+
if tuple(map(int, pgstac_version.split("."))) < (0, 9, 2):
450+
pass
451+
442452
data = load_test_data("test_collection.json")
443453
collection_id = data["id"]
444454
for ii in range(0, 12):
@@ -468,9 +478,13 @@ async def test_all_collections_with_pagination(app_client, load_test_data):
468478
assert {"root", "self"} == {link["rel"] for link in links}
469479

470480

471-
@requires_pgstac_0_9_2
472481
@pytest.mark.asyncio
473482
async def test_all_collections_without_pagination(app_client_no_ext, load_test_data):
483+
res = await app_client_no_ext.get("/_mgmt/health")
484+
pgstac_version = res.json()["pgstac"]["pgstac_version"]
485+
if tuple(map(int, pgstac_version.split("."))) < (0, 9, 2):
486+
pass
487+
474488
data = load_test_data("test_collection.json")
475489
collection_id = data["id"]
476490
for ii in range(0, 12):
@@ -491,11 +505,15 @@ async def test_all_collections_without_pagination(app_client_no_ext, load_test_d
491505
assert {"root", "self"} == {link["rel"] for link in links}
492506

493507

494-
@requires_pgstac_0_9_2
495508
@pytest.mark.asyncio
496509
async def test_get_collections_search_pagination(
497510
app_client, load_test_collection, load_test2_collection
498511
):
512+
res = await app_client.get("/_mgmt/health")
513+
pgstac_version = res.json()["pgstac"]["pgstac_version"]
514+
if tuple(map(int, pgstac_version.split("."))) < (0, 9, 2):
515+
pass
516+
499517
resp = await app_client.get("/collections")
500518
assert resp.json()["numberReturned"] == 2
501519
assert resp.json()["numberMatched"] == 2
@@ -621,12 +639,16 @@ async def test_get_collections_search_pagination(
621639
assert {"root", "self"} == {link["rel"] for link in links}
622640

623641

624-
@requires_pgstac_0_9_2
625642
@pytest.mark.xfail(strict=False)
626643
@pytest.mark.asyncio
627644
async def test_get_collections_search_offset_1(
628645
app_client, load_test_collection, load_test2_collection
629646
):
647+
res = await app_client.get("/_mgmt/health")
648+
pgstac_version = res.json()["pgstac"]["pgstac_version"]
649+
if tuple(map(int, pgstac_version.split("."))) < (0, 9, 2):
650+
pass
651+
630652
# BUG: pgstac doesn't return a `prev` link when limit is not set
631653
# offset=1, should have a `previous` link
632654
resp = await app_client.get(

tests/resources/test_item.py

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@
1818

1919
from stac_fastapi.pgstac.models.links import CollectionLinks
2020

21-
from ..conftest import requires_pgstac_0_9_2
22-
2321

2422
async def test_create_collection(app_client, load_test_data: Callable):
2523
in_json = load_test_data("test_collection.json")
@@ -1693,9 +1691,13 @@ async def test_get_search_link_media(app_client):
16931691
assert get_self_link["type"] == "application/geo+json"
16941692

16951693

1696-
@requires_pgstac_0_9_2
16971694
@pytest.mark.asyncio
16981695
async def test_item_search_freetext(app_client, load_test_data, load_test_collection):
1696+
res = await app_client.get("/_mgmt/health")
1697+
pgstac_version = res.json()["pgstac"]["pgstac_version"]
1698+
if tuple(map(int, pgstac_version.split("."))) < (0, 9, 2):
1699+
pass
1700+
16991701
test_item = load_test_data("test_item.json")
17001702
resp = await app_client.post(
17011703
f"/collections/{test_item['collection']}/items", json=test_item
@@ -1722,3 +1724,73 @@ async def test_item_search_freetext(app_client, load_test_data, load_test_collec
17221724
params={"q": "yo"},
17231725
)
17241726
assert resp.json()["numberReturned"] == 0
1727+
1728+
1729+
@pytest.mark.asyncio
1730+
async def test_item_asset_change(app_client, load_test_data):
1731+
"""Check that changing item_assets in collection does
1732+
not affect existing items if hydration should not occur.
1733+
1734+
"""
1735+
# load collection
1736+
data = load_test_data("test2_collection.json")
1737+
collection_id = data["id"]
1738+
1739+
resp = await app_client.post("/collections", json=data)
1740+
assert "item_assets" in data
1741+
assert resp.status_code == 201
1742+
assert "item_assets" in resp.json()
1743+
1744+
# load items
1745+
test_item = load_test_data("test2_item.json")
1746+
resp = await app_client.post(f"/collections/{collection_id}/items", json=test_item)
1747+
assert resp.status_code == 201
1748+
1749+
# check list of items
1750+
resp = await app_client.get(
1751+
f"/collections/{collection_id}/items", params={"limit": 1}
1752+
)
1753+
assert len(resp.json()["features"]) == 1
1754+
assert resp.status_code == 200
1755+
1756+
# NOTE: API or PgSTAC Hydration we should get the same values as original Item
1757+
assert (
1758+
test_item["assets"]["red"]["raster:bands"]
1759+
== resp.json()["features"][0]["assets"]["red"]["raster:bands"]
1760+
)
1761+
1762+
# NOTE: `description` is not in the item body but in the collection's item-assets
1763+
# because it's not in the original item it won't be hydrated
1764+
assert not resp.json()["features"][0]["assets"]["qa_pixel"].get("description")
1765+
1766+
###########################################################################
1767+
# Remove item_assets in collection
1768+
operations = [{"op": "remove", "path": "/item_assets"}]
1769+
resp = await app_client.patch(f"/collections/{collection_id}", json=operations)
1770+
assert resp.status_code == 200
1771+
1772+
# Make sure item_assets is not in collection response
1773+
resp = await app_client.get(f"/collections/{collection_id}")
1774+
assert resp.status_code == 200
1775+
assert "item_assets" not in resp.json()
1776+
###########################################################################
1777+
1778+
resp = await app_client.get(
1779+
f"/collections/{collection_id}/items", params={"limit": 1}
1780+
)
1781+
assert len(resp.json()["features"]) == 1
1782+
assert resp.status_code == 200
1783+
1784+
# NOTE: here we should only get `scale`, `offset` and `spatial_resolution`
1785+
# because the other values were stripped on ingestion (dehydration is a default in PgSTAC)
1786+
# scale and offset are no in item-asset and spatial_resolution is different, so the value in the item body is kept
1787+
assert ["scale", "offset", "spatial_resolution"] == list(
1788+
resp.json()["features"][0]["assets"]["red"]["raster:bands"][0]
1789+
)
1790+
1791+
# Only run this test for PgSTAC hydratation because `exclude_hydrate_markers=True` by default
1792+
if not app_client._transport.app.state.settings.use_api_hydrate:
1793+
# NOTE: `description` is not in the original item but in the collection's item-assets
1794+
# We get "𒍟※" because PgSTAC set it when ingesting (`description`is item-assets)
1795+
# because we removed item-assets, pgstac cannot hydrate this field, and thus return "𒍟※"
1796+
assert resp.json()["features"][0]["assets"]["qa_pixel"]["description"] == "𒍟※"

0 commit comments

Comments
 (0)