Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve RFC 3339 datetime handling #368

Merged
merged 31 commits into from
Apr 14, 2022
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
eb8f9b0
improve RFC 3339 datetime handling
philvarner Mar 2, 2022
7a751fe
install build-essential
philvarner Mar 2, 2022
15b6ff6
sudo, rearrange files
philvarner Mar 2, 2022
eec3971
gapt -> apt
philvarner Mar 2, 2022
0c9b56e
install build-essential in docker img
philvarner Mar 2, 2022
17fb42d
move ciso8601 dep
philvarner Mar 2, 2022
6666af5
fix typing for parse_interval function
philvarner Mar 2, 2022
1238bc1
fix import of parse_interval
philvarner Mar 2, 2022
36e5bdd
more changes for datetimes
philvarner Mar 2, 2022
e36a8c9
get the types right
philvarner Mar 2, 2022
94af4e7
remove unnecessary parens
philvarner Mar 2, 2022
755220d
remove classmethod
philvarner Mar 2, 2022
0f27bfc
add some interval tests
philvarner Mar 2, 2022
45747f5
replace rfc3339_str with pystac.utils.datetime_to_str
philvarner Mar 3, 2022
d933be7
fix search datetime parameter parsing to support empty string as open…
philvarner Mar 3, 2022
c9f943c
rename methods
philvarner Mar 3, 2022
afb4176
fix accidental method name repacement for parse_rfc3339
philvarner Mar 5, 2022
7b00142
update tests for double open ended temporal interval
philvarner Mar 5, 2022
c9b83de
fix handling of empty string open-ended interval
philvarner Mar 6, 2022
b9119b4
fix test that was successful with double-open-ended datetime interval…
philvarner Mar 31, 2022
9f400f4
replace ciso8601 with python-dateutil
philvarner Apr 1, 2022
f325ebb
Revert "replace ciso8601 with python-dateutil"
philvarner Apr 1, 2022
a77d05e
add pystac dependency to types
philvarner Apr 1, 2022
d535c32
add double-open-ended tests to pgstac tests
philvarner Apr 1, 2022
b7a527c
skip mixed open-ended in pgstac
philvarner Apr 1, 2022
c7b1a8e
skip datetime interval empty string in pgstac
philvarner Apr 1, 2022
11e55f7
maybe just await the test
philvarner Apr 1, 2022
cb2676a
Merge branch 'master' into pv/datetime-parsing
moradology Apr 14, 2022
8a433e8
Bump black version to avoid https://github.com/psf/black/issues/2964
moradology Apr 14, 2022
73c32d9
Merge branch 'pv/datetime-parsing' of github.com:philvarner/stac-fast…
moradology Apr 14, 2022
35357e9
lint
moradology Apr 14, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Dockerfile.docs
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
FROM python:3.8-slim

# build-essential is required to build a wheel for ciso8601
RUN apt update && apt install -y build-essential

RUN python -m pip install --upgrade pip
RUN python -m pip install mkdocs mkdocs-material pdocs

COPY . /opt/src

WORKDIR /opt/src

RUN python -m pip install -e \
RUN python -m pip install \
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-e isn't needed in this case, and it only affects the first package name after it (rather than all of them)

stac_fastapi/api \
stac_fastapi/types \
stac_fastapi/extensions \
stac_fastapi/sqlalchemy


CMD ["pdocs", \
"as_markdown", \
"--output_dir", \
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ pip install -e stac_fastapi/pgstac
```

## Local Development

Use docker-compose to deploy the application, migrate the database, and ingest some example data:
```bash
docker-compose build
Expand All @@ -73,11 +74,23 @@ docker-compose up app-sqlalchemy
docker-compose up app-pgstac
```

For local development it is often more convenient to run the application outside of docker-compose:
For local development it is often more convenient to run the application outside docker-compose:
```bash
make docker-run
```

Before commit, install the [pre-commit](https://pre-commit.com) hooks with:

```shell
pre-commit install
```

The pre-commit hooks can be run manually with:

```shell
pre-commit run --all-files
```

#### Note to Docker for Windows users

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`.
Expand Down
2 changes: 1 addition & 1 deletion stac_fastapi/pgstac/stac_fastapi/pgstac/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ async def item_collection(
Called with `GET /collections/{collection_id}/items`

Args:
id: id of the collection.
collection_id: id of the collection.
limit: number of items to return.
token: pagination token.

Expand Down
50 changes: 21 additions & 29 deletions stac_fastapi/pgstac/tests/resources/test_item.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import json
import uuid
from datetime import datetime, timedelta
from datetime import timedelta
from typing import Callable
from urllib.parse import parse_qs, urljoin, urlparse

import pystac
import pytest
from httpx import AsyncClient
from pystac.utils import datetime_to_str
from shapely.geometry import Polygon
from stac_pydantic import Collection, Item
from stac_pydantic.shared import DATETIME_RFC339
from starlette.requests import Request

from stac_fastapi.pgstac.models.links import CollectionLinks
from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime


@pytest.mark.asyncio
Expand Down Expand Up @@ -402,14 +403,14 @@ async def test_item_search_temporal_query_post(
)
assert resp.status_code == 200

item_date = datetime.strptime(test_item["properties"]["datetime"], DATETIME_RFC339)
item_date = rfc3339_str_to_datetime(test_item["properties"]["datetime"])
print(item_date)
item_date = item_date + timedelta(seconds=1)

params = {
"collections": [test_item["collection"]],
"intersects": test_item["geometry"],
"datetime": item_date.strftime(DATETIME_RFC339),
"datetime": datetime_to_str(item_date),
}

resp = await app_client.post("/search", json=params)
Expand Down Expand Up @@ -437,14 +438,15 @@ async def test_item_search_temporal_window_post(
)
assert resp.status_code == 200

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

params = {
"collections": [test_item["collection"]],
"datetime": f"{item_date_before.strftime(DATETIME_RFC339)}/{item_date_after.strftime(DATETIME_RFC339)}",
"datetime": f"{datetime_to_str(item_date_before)}/{datetime_to_str(item_date_after)}",
}

resp = await app_client.post("/search", json=params)
resp_json = resp.json()
assert len(resp_json["features"]) == 1
Expand All @@ -455,34 +457,24 @@ async def test_item_search_temporal_window_post(
async def test_item_search_temporal_open_window(
app_client, load_test_data, load_test_collection
):
"""Test POST search with open spatio-temporal query (core)"""
test_item = load_test_data("test_item.json")
resp = await app_client.post(
f"/collections/{test_item['collection']}/items", json=test_item
)
assert resp.status_code == 200

# Add second item with a different datetime.
second_test_item = load_test_data("test_item2.json")
resp = await app_client.post(
f"/collections/{test_item['collection']}/items", json=second_test_item
)
assert resp.status_code == 200

params = {
"collections": [test_item["collection"]],
"datetime": "../..",
}
resp = await app_client.post("/search", json=params)
resp_json = resp.json()
assert len(resp_json["features"]) == 2
assert resp.status_code == 400

params = {
"datetime": "/",
}
resp = await app_client.post("/search", json=params)
assert resp.status_code == 400


@pytest.mark.asyncio
async def test_item_search_sort_post(app_client, load_test_data, load_test_collection):
"""Test POST search with sorting (sort extension)"""
first_item = load_test_data("test_item.json")
item_date = datetime.strptime(first_item["properties"]["datetime"], DATETIME_RFC339)
item_date = rfc3339_str_to_datetime(first_item["properties"]["datetime"])
resp = await app_client.post(
f"/collections/{first_item['collection']}/items", json=first_item
)
Expand All @@ -491,7 +483,7 @@ async def test_item_search_sort_post(app_client, load_test_data, load_test_colle
second_item = load_test_data("test_item.json")
second_item["id"] = "another-item"
another_item_date = item_date - timedelta(days=1)
second_item["properties"]["datetime"] = another_item_date.strftime(DATETIME_RFC339)
second_item["properties"]["datetime"] = datetime_to_str(another_item_date)
resp = await app_client.post(
f"/collections/{second_item['collection']}/items", json=second_item
)
Expand Down Expand Up @@ -601,13 +593,13 @@ async def test_item_search_temporal_window_get(
)
assert resp.status_code == 200

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

params = {
"collections": test_item["collection"],
"datetime": f"{item_date_before.strftime(DATETIME_RFC339)}/{item_date_after.strftime(DATETIME_RFC339)}",
"datetime": f"{datetime_to_str(item_date_before)}/{datetime_to_str(item_date_after)}",
}
resp = await app_client.get("/search", params=params)
resp_json = resp.json()
Expand All @@ -619,7 +611,7 @@ async def test_item_search_temporal_window_get(
async def test_item_search_sort_get(app_client, load_test_data, load_test_collection):
"""Test GET search with sorting (sort extension)"""
first_item = load_test_data("test_item.json")
item_date = datetime.strptime(first_item["properties"]["datetime"], DATETIME_RFC339)
item_date = rfc3339_str_to_datetime(first_item["properties"]["datetime"])
resp = await app_client.post(
f"/collections/{first_item['collection']}/items", json=first_item
)
Expand All @@ -628,7 +620,7 @@ async def test_item_search_sort_get(app_client, load_test_data, load_test_collec
second_item = load_test_data("test_item.json")
second_item["id"] = "another-item"
another_item_date = item_date - timedelta(days=1)
second_item["properties"]["datetime"] = another_item_date.strftime(DATETIME_RFC339)
second_item["properties"]["datetime"] = datetime_to_str(another_item_date)
resp = await app_client.post(
f"/collections/{second_item['collection']}/items", json=second_item
)
Expand Down
7 changes: 4 additions & 3 deletions stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,13 +349,14 @@ def post_search(
# Non-interval date ex. "2000-02-02T00:00:00.00Z"
if len(dts) == 1:
query = query.filter(self.item_table.datetime == dts[0])
elif ".." not in search_request.datetime:
# is there a benefit to between instead of >= and <= ?
elif dts[0] not in ["", ".."] and dts[1] not in ["", ".."]:
query = query.filter(self.item_table.datetime.between(*dts))
# All items after the start date
elif dts[0] != "..":
elif dts[0] not in ["", ".."]:
query = query.filter(self.item_table.datetime >= dts[0])
# All items before the end date
elif dts[1] != "..":
elif dts[1] not in ["", ".."]:
query = query.filter(self.item_table.datetime <= dts[1])

# Query fields
Expand Down
10 changes: 5 additions & 5 deletions stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/serializers.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
"""Serializers."""
import abc
import json
from datetime import datetime
from typing import TypedDict

import attr
import geoalchemy2 as ga
from stac_pydantic.shared import DATETIME_RFC339
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DATETIME_RFC339 has been deprecated in stac_pydantic. It has several problems including, not parsing fractional seconds or timezones other than Z.

from pystac.utils import datetime_to_str

from stac_fastapi.sqlalchemy.models import database
from stac_fastapi.types import stac as stac_types
from stac_fastapi.types.config import Settings
from stac_fastapi.types.links import CollectionLinks, ItemLinks, resolve_links
from stac_fastapi.types.rfc3339 import now_to_rfc3339_str, rfc3339_str_to_datetime


@attr.s # type:ignore
Expand Down Expand Up @@ -55,7 +55,7 @@ def db_to_stac(cls, db_model: database.Item, base_url: str) -> stac_types.Item:
# Use getattr to accommodate extension namespaces
field_value = getattr(db_model, field.split(":")[-1])
if field == "datetime":
field_value = field_value.strftime(DATETIME_RFC339)
field_value = datetime_to_str(field_value)
properties[field] = field_value
item_id = db_model.id
collection_id = db_model.collection_id
Expand Down Expand Up @@ -101,12 +101,12 @@ def stac_to_db(
# Use getattr to accommodate extension namespaces
field_value = stac_data["properties"][field]
if field == "datetime":
field_value = datetime.strptime(field_value, DATETIME_RFC339)
field_value = rfc3339_str_to_datetime(field_value)
indexed_fields[field.split(":")[-1]] = field_value

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

now = datetime.utcnow().strftime(DATETIME_RFC339)
now = now_to_rfc3339_str()
if "created" not in stac_data["properties"]:
stac_data["properties"]["created"] = now
stac_data["properties"]["updated"] = now
Expand Down
34 changes: 15 additions & 19 deletions stac_fastapi/sqlalchemy/tests/resources/test_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@

import pystac
from pydantic.datetime_parse import parse_datetime
from pystac.utils import datetime_to_str
from shapely.geometry import Polygon
from stac_pydantic.shared import DATETIME_RFC339

from stac_fastapi.sqlalchemy.core import CoreCrudClient
from stac_fastapi.types.core import LandingPageMixin
from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime


def test_create_and_delete_item(app_client, load_test_data):
Expand Down Expand Up @@ -419,13 +420,13 @@ def test_item_search_temporal_query_post(app_client, load_test_data):
)
assert resp.status_code == 200

item_date = datetime.strptime(test_item["properties"]["datetime"], DATETIME_RFC339)
item_date = rfc3339_str_to_datetime(test_item["properties"]["datetime"])
item_date = item_date + timedelta(seconds=1)

params = {
"collections": [test_item["collection"]],
"intersects": test_item["geometry"],
"datetime": f"../{item_date.strftime(DATETIME_RFC339)}",
"datetime": f"../{datetime_to_str(item_date)}",
}
resp = app_client.post("/search", json=params)
resp_json = resp.json()
Expand All @@ -440,14 +441,14 @@ def test_item_search_temporal_window_post(app_client, load_test_data):
)
assert resp.status_code == 200

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

params = {
"collections": [test_item["collection"]],
"intersects": test_item["geometry"],
"datetime": f"{item_date_before.strftime(DATETIME_RFC339)}/{item_date_after.strftime(DATETIME_RFC339)}",
"datetime": f"{datetime_to_str(item_date_before)}/{datetime_to_str(item_date_after)}",
}
resp = app_client.post("/search", json=params)
resp_json = resp.json()
Expand All @@ -462,20 +463,15 @@ def test_item_search_temporal_open_window(app_client, load_test_data):
)
assert resp.status_code == 200

params = {
"collections": [test_item["collection"]],
"intersects": test_item["geometry"],
"datetime": "../..",
}
resp = app_client.post("/search", json=params)
resp_json = resp.json()
assert resp_json["features"][0]["id"] == test_item["id"]
for dt in ["/", "../", "/..", "../.."]:
resp = app_client.post("/search", json={"datetime": dt})
assert resp.status_code == 400


def test_item_search_sort_post(app_client, load_test_data):
"""Test POST search with sorting (sort extension)"""
first_item = load_test_data("test_item.json")
item_date = datetime.strptime(first_item["properties"]["datetime"], DATETIME_RFC339)
item_date = rfc3339_str_to_datetime(first_item["properties"]["datetime"])
resp = app_client.post(
f"/collections/{first_item['collection']}/items", json=first_item
)
Expand All @@ -484,7 +480,7 @@ def test_item_search_sort_post(app_client, load_test_data):
second_item = load_test_data("test_item.json")
second_item["id"] = "another-item"
another_item_date = item_date - timedelta(days=1)
second_item["properties"]["datetime"] = another_item_date.strftime(DATETIME_RFC339)
second_item["properties"]["datetime"] = datetime_to_str(another_item_date)
resp = app_client.post(
f"/collections/{second_item['collection']}/items", json=second_item
)
Expand Down Expand Up @@ -563,14 +559,14 @@ def test_item_search_temporal_window_get(app_client, load_test_data):
)
assert resp.status_code == 200

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

params = {
"collections": test_item["collection"],
"bbox": ",".join([str(coord) for coord in test_item["bbox"]]),
"datetime": f"{item_date_before.strftime(DATETIME_RFC339)}/{item_date_after.strftime(DATETIME_RFC339)}",
"datetime": f"{datetime_to_str(item_date_before)}/{datetime_to_str(item_date_after)}",
}
resp = app_client.get("/search", params=params)
resp_json = resp.json()
Expand All @@ -580,7 +576,7 @@ def test_item_search_temporal_window_get(app_client, load_test_data):
def test_item_search_sort_get(app_client, load_test_data):
"""Test GET search with sorting (sort extension)"""
first_item = load_test_data("test_item.json")
item_date = datetime.strptime(first_item["properties"]["datetime"], DATETIME_RFC339)
item_date = rfc3339_str_to_datetime(first_item["properties"]["datetime"])
resp = app_client.post(
f"/collections/{first_item['collection']}/items", json=first_item
)
Expand All @@ -589,7 +585,7 @@ def test_item_search_sort_get(app_client, load_test_data):
second_item = load_test_data("test_item.json")
second_item["id"] = "another-item"
another_item_date = item_date - timedelta(days=1)
second_item["properties"]["datetime"] = another_item_date.strftime(DATETIME_RFC339)
second_item["properties"]["datetime"] = datetime_to_str(another_item_date)
resp = app_client.post(
f"/collections/{second_item['collection']}/items", json=second_item
)
Expand Down
2 changes: 2 additions & 0 deletions stac_fastapi/types/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"attrs",
"pydantic[dotenv]",
"stac_pydantic==2.0.*",
"pystac==1.*",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it took me 2 hours to figure out that the docs broke because I'd added an import from pystac, but it's not a dependency of this package. The tests worked because everything gets installed when they run.

"ciso8601~=2.2.0",
]

extra_reqs = {
Expand Down
Loading