Skip to content
This repository was archived by the owner on Jul 21, 2025. It is now read-only.

Commit 3d370ae

Browse files
authored
feat: pgstac backend (#31)
- Closes #11
1 parent e6a05e1 commit 3d370ae

File tree

7 files changed

+498
-10
lines changed

7 files changed

+498
-10
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ jobs:
1313
steps:
1414
- uses: actions/checkout@v4
1515
- uses: astral-sh/setup-uv@v5
16+
- name: Install postgis
17+
run: sudo apt-get install postgis
1618
- name: Install
1719
run: scripts/install --dev
1820
- name: Lint

pyproject.toml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,35 @@ readme = "README.md"
66
requires-python = ">=3.12"
77
dependencies = [
88
"fastapi[standard]>=0.115.6",
9+
"pgstacrs>=0.1.0",
910
"pydantic>=2.10.4",
1011
"pydantic-settings>=2.7.1",
1112
"stacrs>=0.3.0",
1213
]
1314

1415
[dependency-groups]
15-
dev = ["mypy>=1.14.1", "pytest>=8.3.4", "pytest-asyncio>=0.25.1", "ruff>=0.8.5"]
16+
dev = [
17+
"mypy>=1.14.1",
18+
"psycopg[pool]>=3.2.3",
19+
"pypgstac>=0.9.2",
20+
"pystac>=1.11.0",
21+
"pytest>=8.3.4",
22+
"pytest-asyncio>=0.25.1",
23+
"pytest-postgresql>=6.1.1",
24+
"ruff>=0.8.5",
25+
]
1626

1727
[tool.mypy]
1828
strict = true
1929
files = ["**/*.py"]
2030

2131
[tool.pytest.ini_options]
2232
asyncio_mode = "auto"
23-
asyncio_default_fixture_loop_scope = "function"
33+
asyncio_default_fixture_loop_scope = "session"
34+
filterwarnings = [
35+
"error",
36+
'ignore:datetime.datetime.utcfromtimestamp\(\) is deprecated and scheduled for removal in a future version.:DeprecationWarning',
37+
]
2438

2539
[tool.ruff.lint]
2640
select = ["E", "F", "I"]

src/tistac/app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from .settings import Settings
1010

1111

12-
def build(settings: Settings | None = None) -> FastAPI:
12+
async def build(settings: Settings | None = None) -> FastAPI:
1313
"""Builds a new TiStac application."""
1414

1515
if settings is None:
@@ -18,7 +18,7 @@ def build(settings: Settings | None = None) -> FastAPI:
1818
async def get_settings() -> Settings:
1919
return settings
2020

21-
backend = settings.get_backend()
21+
backend = await settings.get_backend()
2222

2323
async def get_backend() -> Backend:
2424
return backend

src/tistac/pgstac.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from __future__ import annotations
2+
3+
from pgstacrs import Client
4+
5+
from .backend import Backend
6+
from .item_collection import ItemCollection
7+
from .search import Search
8+
9+
10+
class PgstacBackend(Backend):
11+
"""A PgSTAC backend."""
12+
13+
@classmethod
14+
async def open(cls, dsn: str) -> PgstacBackend:
15+
client = await Client.open(dsn)
16+
return PgstacBackend(client)
17+
18+
def __init__(self, client: Client) -> None:
19+
self.client = client
20+
21+
async def search(self, search: Search) -> ItemCollection:
22+
item_collection = await self.client.search(limit=search.limit)
23+
return ItemCollection.model_validate(item_collection)

src/tistac/settings.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import urllib.parse
2+
13
from pydantic import Field
24
from pydantic_settings import BaseSettings, SettingsConfigDict
35

@@ -14,11 +16,16 @@ class Settings(BaseSettings):
1416
backend: str
1517
default_limit: int = Field(default=DEFAULT_LIMIT)
1618

17-
def get_backend(self) -> Backend:
19+
async def get_backend(self) -> Backend:
1820
"""Returns the configured backend."""
21+
from .pgstac import PgstacBackend
1922
from .stac_geoparquet import StacGeoparquetBackend
2023

21-
return StacGeoparquetBackend(self.backend)
24+
url = urllib.parse.urlparse(self.backend)
25+
if url.scheme == "postgresql":
26+
return await PgstacBackend.open(self.backend)
27+
else:
28+
return StacGeoparquetBackend(self.backend)
2229

2330
def update_search(self, search: Search) -> Search:
2431
"""Updates a search with some default settings."""

tests/conftest.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,66 @@
11
from pathlib import Path
2+
from typing import AsyncIterator
23

34
import pytest
5+
import stacrs
46
from fastapi.testclient import TestClient
7+
from pgstacrs import Client
8+
from pypgstac.db import PgstacDB
9+
from pypgstac.migrate import Migrate
10+
from pystac import Collection, Extent, Item
511
from pytest import FixtureRequest
12+
from pytest_postgresql import factories
13+
from pytest_postgresql.executor import PostgreSQLExecutor
14+
from pytest_postgresql.janitor import DatabaseJanitor
615

716
import tistac.app
817
from tistac import Settings
918

19+
naip_items = Path(__file__).parents[1] / "data" / "naip.parquet"
1020

11-
@pytest.fixture(params=[str(Path(__file__).parents[1] / "data" / "naip.parquet")])
12-
def client(request: FixtureRequest) -> TestClient:
13-
settings = Settings(backend=request.param)
14-
return TestClient(tistac.app.build(settings))
21+
pgstac_proc = factories.postgresql_proc()
22+
23+
24+
@pytest.fixture(scope="session")
25+
async def pgstac(pgstac_proc: PostgreSQLExecutor) -> AsyncIterator[PostgreSQLExecutor]:
26+
dsn = f"postgresql://{pgstac_proc.user}:{pgstac_proc.password}@{pgstac_proc.host}:{pgstac_proc.port}/{pgstac_proc.template_dbname}"
27+
pgstac_db = PgstacDB(dsn)
28+
Migrate(pgstac_db).run_migration()
29+
items = stacrs.read(str(naip_items))["features"][
30+
0:100
31+
] # pgstac takes too long to load 10000 items
32+
extent = Extent.from_items((Item.from_dict(d) for d in items))
33+
collection = Collection(
34+
id="naip", description="Test NAIP collection", extent=extent
35+
)
36+
client = await Client.open(dsn)
37+
await client.create_collection(
38+
collection.to_dict(include_self_link=False, transform_hrefs=False)
39+
)
40+
await client.create_items(items)
41+
yield pgstac_proc
42+
43+
44+
@pytest.fixture(params=["stac-geoparquet", "pgstac"])
45+
async def client(
46+
request: FixtureRequest, pgstac: PostgreSQLExecutor
47+
) -> AsyncIterator[TestClient]:
48+
if request.param == "stac-geoparquet":
49+
settings = Settings(backend=str(naip_items))
50+
yield TestClient(await tistac.app.build(settings))
51+
elif request.param == "pgstac":
52+
with DatabaseJanitor(
53+
user=pgstac.user,
54+
host=pgstac.host,
55+
port=pgstac.port,
56+
version=pgstac.version,
57+
password=pgstac.password,
58+
dbname="pypgstac_test",
59+
template_dbname=pgstac.template_dbname,
60+
) as database_janitor:
61+
settings = Settings(
62+
backend=f"postgresql://{database_janitor.user}:{database_janitor.password}@{database_janitor.host}:{database_janitor.port}/{database_janitor.dbname}"
63+
)
64+
yield TestClient(await tistac.app.build(settings))
65+
else:
66+
raise Exception(f"Unknown backend type: {request.param}")

0 commit comments

Comments
 (0)