Skip to content

Commit 99ce774

Browse files
Phil Varnermoradology
andauthored
Improve RFC 3339 datetime handling (#368)
* improve RFC 3339 datetime handling * install build-essential * sudo, rearrange files * gapt -> apt * install build-essential in docker img * move ciso8601 dep * fix typing for parse_interval function * fix import of parse_interval * more changes for datetimes * get the types right * remove unnecessary parens * remove classmethod * add some interval tests * replace rfc3339_str with pystac.utils.datetime_to_str * fix search datetime parameter parsing to support empty string as open end of interval * rename methods * fix accidental method name repacement for parse_rfc3339 * update tests for double open ended temporal interval * fix handling of empty string open-ended interval * fix test that was successful with double-open-ended datetime interval to now fail * replace ciso8601 with python-dateutil * Revert "replace ciso8601 with python-dateutil" This reverts commit 9f400f4. * add pystac dependency to types * add double-open-ended tests to pgstac tests * skip mixed open-ended in pgstac * skip datetime interval empty string in pgstac * maybe just await the test * Bump black version to avoid psf/black#2964 * lint Co-authored-by: Nathan Zimmerman <[email protected]>
1 parent c8c8819 commit 99ce774

File tree

16 files changed

+267
-98
lines changed

16 files changed

+267
-98
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ repos:
66
language_version: python3.8
77
-
88
repo: https://github.com/psf/black
9-
rev: 20.8b1
9+
rev: 22.3.0
1010
hooks:
1111
- id: black
1212
args: ['--safe']

Dockerfile.docs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
FROM python:3.8-slim
22

3+
# build-essential is required to build a wheel for ciso8601
4+
RUN apt update && apt install -y build-essential
5+
36
RUN python -m pip install --upgrade pip
47
RUN python -m pip install mkdocs mkdocs-material pdocs
58

69
COPY . /opt/src
710

811
WORKDIR /opt/src
912

10-
RUN python -m pip install -e \
13+
RUN python -m pip install \
1114
stac_fastapi/api \
1215
stac_fastapi/types \
1316
stac_fastapi/extensions \
1417
stac_fastapi/sqlalchemy
1518

16-
1719
CMD ["pdocs", \
1820
"as_markdown", \
1921
"--output_dir", \

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ pip install -e stac_fastapi/pgstac
6262
```
6363

6464
## Local Development
65+
6566
Use docker-compose to deploy the application, migrate the database, and ingest some example data:
6667
```bash
6768
docker-compose build
@@ -73,11 +74,23 @@ docker-compose up app-sqlalchemy
7374
docker-compose up app-pgstac
7475
```
7576

76-
For local development it is often more convenient to run the application outside of docker-compose:
77+
For local development it is often more convenient to run the application outside docker-compose:
7778
```bash
7879
make docker-run
7980
```
8081

82+
Before commit, install the [pre-commit](https://pre-commit.com) hooks with:
83+
84+
```shell
85+
pre-commit install
86+
```
87+
88+
The pre-commit hooks can be run manually with:
89+
90+
```shell
91+
pre-commit run --all-files
92+
```
93+
8194
#### Note to Docker for Windows users
8295

8396
You'll need to enable experimental features on Docker for Windows in order to run the docker-compose, due to the "--platform" flag that is required to allow the project to run on some Apple architectures. To do this, open Docker Desktop, go to settings, select "Docker Engine", and modify the configuration JSON to have `"experimental": true`.

stac_fastapi/api/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"License :: OSI Approved :: MIT License",
4141
],
4242
keywords="STAC FastAPI COG",
43-
author=u"Arturo Engineering",
43+
author="Arturo Engineering",
4444
author_email="[email protected]",
4545
url="https://github.com/stac-utils/stac-fastapi",
4646
license="MIT",

stac_fastapi/api/stac_fastapi/api/models.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,6 @@ class GeoJSONResponse(ORJSONResponse):
160160

161161
media_type = "application/geo+json"
162162

163-
164163
else:
165164
from starlette.responses import JSONResponse
166165

stac_fastapi/extensions/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"License :: OSI Approved :: MIT License",
4040
],
4141
keywords="STAC FastAPI COG",
42-
author=u"Arturo Engineering",
42+
author="Arturo Engineering",
4343
author_email="[email protected]",
4444
url="https://github.com/stac-utils/stac-fastapi",
4545
license="MIT",

stac_fastapi/pgstac/stac_fastapi/pgstac/core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ async def item_collection(
186186
Called with `GET /collections/{collection_id}/items`
187187
188188
Args:
189-
id: id of the collection.
189+
collection_id: id of the collection.
190190
limit: number of items to return.
191191
token: pagination token.
192192

stac_fastapi/pgstac/tests/resources/test_item.py

Lines changed: 17 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
import json
22
import uuid
3-
from datetime import datetime, timedelta
3+
from datetime import timedelta
44
from typing import Callable
55
from urllib.parse import parse_qs, urljoin, urlparse
66

77
import pystac
88
import pytest
99
from httpx import AsyncClient
10+
from pystac.utils import datetime_to_str
1011
from shapely.geometry import Polygon
1112
from stac_pydantic import Collection, Item
12-
from stac_pydantic.shared import DATETIME_RFC339
1313
from starlette.requests import Request
1414

1515
from stac_fastapi.pgstac.models.links import CollectionLinks
16+
from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime
1617

1718

1819
@pytest.mark.asyncio
@@ -402,14 +403,14 @@ async def test_item_search_temporal_query_post(
402403
)
403404
assert resp.status_code == 200
404405

405-
item_date = datetime.strptime(test_item["properties"]["datetime"], DATETIME_RFC339)
406+
item_date = rfc3339_str_to_datetime(test_item["properties"]["datetime"])
406407
print(item_date)
407408
item_date = item_date + timedelta(seconds=1)
408409

409410
params = {
410411
"collections": [test_item["collection"]],
411412
"intersects": test_item["geometry"],
412-
"datetime": item_date.strftime(DATETIME_RFC339),
413+
"datetime": datetime_to_str(item_date),
413414
}
414415

415416
resp = await app_client.post("/search", json=params)
@@ -437,14 +438,15 @@ async def test_item_search_temporal_window_post(
437438
)
438439
assert resp.status_code == 200
439440

440-
item_date = datetime.strptime(test_item["properties"]["datetime"], DATETIME_RFC339)
441+
item_date = rfc3339_str_to_datetime(test_item["properties"]["datetime"])
441442
item_date_before = item_date - timedelta(seconds=1)
442443
item_date_after = item_date + timedelta(seconds=1)
443444

444445
params = {
445446
"collections": [test_item["collection"]],
446-
"datetime": f"{item_date_before.strftime(DATETIME_RFC339)}/{item_date_after.strftime(DATETIME_RFC339)}",
447+
"datetime": f"{datetime_to_str(item_date_before)}/{datetime_to_str(item_date_after)}",
447448
}
449+
448450
resp = await app_client.post("/search", json=params)
449451
resp_json = resp.json()
450452
assert len(resp_json["features"]) == 1
@@ -455,34 +457,16 @@ async def test_item_search_temporal_window_post(
455457
async def test_item_search_temporal_open_window(
456458
app_client, load_test_data, load_test_collection
457459
):
458-
"""Test POST search with open spatio-temporal query (core)"""
459-
test_item = load_test_data("test_item.json")
460-
resp = await app_client.post(
461-
f"/collections/{test_item['collection']}/items", json=test_item
462-
)
463-
assert resp.status_code == 200
464-
465-
# Add second item with a different datetime.
466-
second_test_item = load_test_data("test_item2.json")
467-
resp = await app_client.post(
468-
f"/collections/{test_item['collection']}/items", json=second_test_item
469-
)
470-
assert resp.status_code == 200
471-
472-
params = {
473-
"collections": [test_item["collection"]],
474-
"datetime": "../..",
475-
}
476-
resp = await app_client.post("/search", json=params)
477-
resp_json = resp.json()
478-
assert len(resp_json["features"]) == 2
460+
for dt in ["/", "../..", "../", "/.."]:
461+
resp = await app_client.post("/search", json={"datetime": dt})
462+
assert resp.status_code == 400
479463

480464

481465
@pytest.mark.asyncio
482466
async def test_item_search_sort_post(app_client, load_test_data, load_test_collection):
483467
"""Test POST search with sorting (sort extension)"""
484468
first_item = load_test_data("test_item.json")
485-
item_date = datetime.strptime(first_item["properties"]["datetime"], DATETIME_RFC339)
469+
item_date = rfc3339_str_to_datetime(first_item["properties"]["datetime"])
486470
resp = await app_client.post(
487471
f"/collections/{first_item['collection']}/items", json=first_item
488472
)
@@ -491,7 +475,7 @@ async def test_item_search_sort_post(app_client, load_test_data, load_test_colle
491475
second_item = load_test_data("test_item.json")
492476
second_item["id"] = "another-item"
493477
another_item_date = item_date - timedelta(days=1)
494-
second_item["properties"]["datetime"] = another_item_date.strftime(DATETIME_RFC339)
478+
second_item["properties"]["datetime"] = datetime_to_str(another_item_date)
495479
resp = await app_client.post(
496480
f"/collections/{second_item['collection']}/items", json=second_item
497481
)
@@ -601,13 +585,13 @@ async def test_item_search_temporal_window_get(
601585
)
602586
assert resp.status_code == 200
603587

604-
item_date = datetime.strptime(test_item["properties"]["datetime"], DATETIME_RFC339)
588+
item_date = rfc3339_str_to_datetime(test_item["properties"]["datetime"])
605589
item_date_before = item_date - timedelta(seconds=1)
606590
item_date_after = item_date + timedelta(seconds=1)
607591

608592
params = {
609593
"collections": test_item["collection"],
610-
"datetime": f"{item_date_before.strftime(DATETIME_RFC339)}/{item_date_after.strftime(DATETIME_RFC339)}",
594+
"datetime": f"{datetime_to_str(item_date_before)}/{datetime_to_str(item_date_after)}",
611595
}
612596
resp = await app_client.get("/search", params=params)
613597
resp_json = resp.json()
@@ -619,7 +603,7 @@ async def test_item_search_temporal_window_get(
619603
async def test_item_search_sort_get(app_client, load_test_data, load_test_collection):
620604
"""Test GET search with sorting (sort extension)"""
621605
first_item = load_test_data("test_item.json")
622-
item_date = datetime.strptime(first_item["properties"]["datetime"], DATETIME_RFC339)
606+
item_date = rfc3339_str_to_datetime(first_item["properties"]["datetime"])
623607
resp = await app_client.post(
624608
f"/collections/{first_item['collection']}/items", json=first_item
625609
)
@@ -628,7 +612,7 @@ async def test_item_search_sort_get(app_client, load_test_data, load_test_collec
628612
second_item = load_test_data("test_item.json")
629613
second_item["id"] = "another-item"
630614
another_item_date = item_date - timedelta(days=1)
631-
second_item["properties"]["datetime"] = another_item_date.strftime(DATETIME_RFC339)
615+
second_item["properties"]["datetime"] = datetime_to_str(another_item_date)
632616
resp = await app_client.post(
633617
f"/collections/{second_item['collection']}/items", json=second_item
634618
)

stac_fastapi/sqlalchemy/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"License :: OSI Approved :: MIT License",
4848
],
4949
keywords="STAC FastAPI COG",
50-
author=u"Arturo Engineering",
50+
author="Arturo Engineering",
5151
author_email="[email protected]",
5252
url="https://github.com/stac-utils/stac-fastapi",
5353
license="MIT",

stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,13 +349,14 @@ def post_search(
349349
# Non-interval date ex. "2000-02-02T00:00:00.00Z"
350350
if len(dts) == 1:
351351
query = query.filter(self.item_table.datetime == dts[0])
352-
elif ".." not in search_request.datetime:
352+
# is there a benefit to between instead of >= and <= ?
353+
elif dts[0] not in ["", ".."] and dts[1] not in ["", ".."]:
353354
query = query.filter(self.item_table.datetime.between(*dts))
354355
# All items after the start date
355-
elif dts[0] != "..":
356+
elif dts[0] not in ["", ".."]:
356357
query = query.filter(self.item_table.datetime >= dts[0])
357358
# All items before the end date
358-
elif dts[1] != "..":
359+
elif dts[1] not in ["", ".."]:
359360
query = query.filter(self.item_table.datetime <= dts[1])
360361

361362
# Query fields

stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/serializers.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
"""Serializers."""
22
import abc
33
import json
4-
from datetime import datetime
54
from typing import TypedDict
65

76
import attr
87
import geoalchemy2 as ga
9-
from stac_pydantic.shared import DATETIME_RFC339
8+
from pystac.utils import datetime_to_str
109

1110
from stac_fastapi.sqlalchemy.models import database
1211
from stac_fastapi.types import stac as stac_types
1312
from stac_fastapi.types.config import Settings
1413
from stac_fastapi.types.links import CollectionLinks, ItemLinks, resolve_links
14+
from stac_fastapi.types.rfc3339 import now_to_rfc3339_str, rfc3339_str_to_datetime
1515

1616

1717
@attr.s # type:ignore
@@ -55,7 +55,7 @@ def db_to_stac(cls, db_model: database.Item, base_url: str) -> stac_types.Item:
5555
# Use getattr to accommodate extension namespaces
5656
field_value = getattr(db_model, field.split(":")[-1])
5757
if field == "datetime":
58-
field_value = field_value.strftime(DATETIME_RFC339)
58+
field_value = datetime_to_str(field_value)
5959
properties[field] = field_value
6060
item_id = db_model.id
6161
collection_id = db_model.collection_id
@@ -101,12 +101,12 @@ def stac_to_db(
101101
# Use getattr to accommodate extension namespaces
102102
field_value = stac_data["properties"][field]
103103
if field == "datetime":
104-
field_value = datetime.strptime(field_value, DATETIME_RFC339)
104+
field_value = rfc3339_str_to_datetime(field_value)
105105
indexed_fields[field.split(":")[-1]] = field_value
106106

107107
# TODO: Exclude indexed fields from the properties jsonb field to prevent duplication
108108

109-
now = datetime.utcnow().strftime(DATETIME_RFC339)
109+
now = now_to_rfc3339_str()
110110
if "created" not in stac_data["properties"]:
111111
stac_data["properties"]["created"] = now
112112
stac_data["properties"]["updated"] = now

0 commit comments

Comments
 (0)