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

feat(v2): first v1 test #1523

Merged
merged 4 commits into from
Feb 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ on:
push:
branches:
- v2
pull_request:
branches:
- v2

jobs:
test:
Expand Down Expand Up @@ -52,6 +55,7 @@ jobs:
path: site/
deploy-docs:
name: Deploy docs
if: github.ref == 'refs/heads/v2'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
requires-python = ">=3.10"
dependencies = ["typing-extensions>=4.12.2"]
dependencies = ["python-dateutil>=2.9.0.post0", "typing-extensions>=4.12.2"]

[project.optional-dependencies]
validate = ["jsonschema>=4.23.0", "referencing>=0.36.2"]
Expand All @@ -35,6 +35,7 @@ dev = [
"pytest>=8.3.4",
"ruff>=0.9.6",
"types-jsonschema>=4.23.0.20241208",
"types-python-dateutil>=2.9.0.20241206",
]
bench = ["asv>=0.6.4"]
docs = ["mike>=2.1.3", "mkdocs-material>=9.6.3", "mkdocstrings-python>=1.14.6"]
Expand Down
9 changes: 9 additions & 0 deletions src/pystac/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,12 @@ def to_dict(self) -> dict[str, Any]:
d = {"href": self.href}
d.update(super().to_dict())
return d


class AssetsMixin:
"""A mixin for things that have assets (Collections and Items)"""

assets: dict[str, Asset]

def add_asset(self, key: str, asset: Asset) -> None:
raise NotImplementedError
26 changes: 24 additions & 2 deletions src/pystac/extent.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from __future__ import annotations

import copy
import datetime
import warnings
from typing import Any, Sequence

from typing_extensions import Self

from .constants import DEFAULT_BBOX, DEFAULT_INTERVAL
from .decorators import v2_deprecated
from .errors import StacWarning
from .types import PermissiveBbox, PermissiveInterval

Expand Down Expand Up @@ -54,7 +56,19 @@ def from_dict(cls: type[Self], d: dict[str, Any]) -> Self:
"""Creates a new spatial extent from a dictionary."""
return cls(**d)

def __init__(self, bbox: PermissiveBbox | None = None):
@classmethod
@v2_deprecated("Use the constructor instead")
def from_coordinates(
cls: type[Self],
coordinates: list[Any],
extra_fields: dict[str, Any] | None = None,
) -> Self:
if extra_fields:
return cls(coordinates, **extra_fields)
else:
return cls(coordinates)

def __init__(self, bbox: PermissiveBbox | None = None, **kwargs: Any):
"""Creates a new spatial extent."""
self.bbox: Sequence[Sequence[float | int]]
if bbox is None or len(bbox) == 0:
Expand All @@ -63,10 +77,13 @@ def __init__(self, bbox: PermissiveBbox | None = None):
self.bbox = bbox # type: ignore
else:
self.bbox = [bbox] # type: ignore
self.extra_fields = kwargs

def to_dict(self) -> dict[str, Any]:
"""Converts this spatial extent to a dictionary."""
return {"bbox": self.bbox}
d = copy.deepcopy(self.extra_fields)
d["bbox"] = self.bbox
return d


class TemporalExtent:
Expand All @@ -77,6 +94,11 @@ def from_dict(cls: type[Self], d: dict[str, Any]) -> Self:
"""Creates a new temporal extent from a dictionary."""
return cls(**d)

@classmethod
def from_now(cls: type[Self]) -> Self:
"""Creates a new temporal extent that starts now and has no end time."""
return cls([[datetime.datetime.now(tz=datetime.timezone.utc), None]])

def __init__(
self,
interval: PermissiveInterval | None = None,
Expand Down
4 changes: 2 additions & 2 deletions src/pystac/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
import warnings
from typing import Any, Sequence

from .asset import Asset
from .asset import Asset, AssetsMixin
from .constants import ITEM_TYPE
from .errors import StacWarning
from .link import Link
from .stac_object import STACObject


class Item(STACObject):
class Item(STACObject, AssetsMixin):
"""An Item is a GeoJSON Feature augmented with foreign members relevant to a
STAC object.

Expand Down
23 changes: 18 additions & 5 deletions src/pystac/stac_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .link import Link

if TYPE_CHECKING:
from .catalog import Catalog
from .io import Read, Write


Expand Down Expand Up @@ -77,13 +78,16 @@ def from_file(

@classmethod
def from_dict(
cls: type[STACObject],
cls: type[Self],
d: dict[str, Any],
*,
href: str | None = None,
root: Catalog | None = None, # TODO deprecation warning
migrate: bool = False,
preserve_dict: bool = True, # TODO deprecation warning
reader: Read | None = None,
writer: Write | None = None,
) -> STACObject:
) -> Self:
"""Creates a STAC object from a dictionary.

If you already know what type of STAC object your dictionary represents,
Expand All @@ -108,17 +112,24 @@ def from_dict(
if type_value == CATALOG_TYPE:
from .catalog import Catalog

return Catalog(**d, href=href, reader=reader, writer=writer)
stac_object: STACObject = Catalog(
**d, href=href, reader=reader, writer=writer
)
elif type_value == COLLECTION_TYPE:
from .collection import Collection

return Collection(**d, href=href, reader=reader, writer=writer)
stac_object = Collection(**d, href=href, reader=reader, writer=writer)
elif type_value == ITEM_TYPE:
from .item import Item

return Item(**d, href=href, reader=reader, writer=writer)
stac_object = Item(**d, href=href, reader=reader, writer=writer)
else:
raise StacError(f"unknown type field: {type_value}")

if isinstance(stac_object, cls):
return stac_object
else:
raise PystacError(f"Expected {cls} but got a {type(stac_object)}")
else:
raise StacError("missing type field on dictionary")

Expand All @@ -136,6 +147,8 @@ def __init__(
"""Creates a new STAC object."""
from .extensions import Extensions

super().__init__()

self.id: str = id
"""The object's id."""

Expand Down
13 changes: 12 additions & 1 deletion tests/test_extent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import pytest

from pystac import StacWarning, TemporalExtent
from pystac import SpatialExtent, StacWarning, TemporalExtent


def test_temporal_with_datetimes() -> None:
Expand Down Expand Up @@ -45,3 +45,14 @@ def test_temporal_with_bad_tail() -> None:
)
d = extent.to_dict()
assert d == {"interval": [["2025-02-11T00:00:00Z", None]]}


def test_temporal_from_now() -> None:
extent = TemporalExtent.from_now()
assert isinstance(extent.interval[0][0], str)
assert extent.interval[0][1] is None


def test_spatial_from_coordinates() -> None:
with pytest.warns(FutureWarning):
SpatialExtent.from_coordinates([-180, -90, 180, 90])
7 changes: 7 additions & 0 deletions tests/test_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,10 @@ def test_warn_include_self_link() -> None:
def test_warn_transform_hrefs() -> None:
with pytest.warns(FutureWarning):
Item("an-id").to_dict(transform_hrefs=True)


def test_from_dict_migrate() -> None:
d = Item("an-id").to_dict()
d["stac_version"] = "1.0.0"
item = Item.from_dict(d, migrate=True)
item.stac_version == "1.1.0"
Empty file added tests/v1/__init__.py
Empty file.
14 changes: 14 additions & 0 deletions tests/v1/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import json
from typing import Any

import pytest

from .utils import TestCases


@pytest.fixture
def sample_item_dict() -> dict[str, Any]:
m = TestCases.get_path("data-files/item/sample-item.json")
with open(m) as f:
item_dict: dict[str, Any] = json.load(f)
return item_dict
81 changes: 81 additions & 0 deletions tests/v1/data-files/item/sample-item.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
{
"type": "Feature",
"stac_version": "1.1.0",
"id": "CS3-20160503_132131_05",
"properties": {
"datetime": "2016-05-03T13:22:30.040000Z",
"title": "A CS3 item",
"license": "PDDL-1.0",
"providers": [
{
"name": "CoolSat",
"roles": [
"producer",
"licensor"
],
"url": "https://cool-sat.com/"
}
]
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
-122.308150179,
37.488035566
],
[
-122.597502109,
37.538869539
],
[
-122.576687533,
37.613537207
],
[
-122.2880486,
37.562818007
],
[
-122.308150179,
37.488035566
]
]
]
},
"links": [
{
"rel": "collection",
"href": "https://raw.githubusercontent.com/radiantearth/stac-spec/v0.8.1/collection-spec/examples/sentinel2.json"
}
],
"assets": {
"analytic": {
"href": "http://cool-sat.com/catalog/CS3-20160503_132130_04/analytic.tif",
"title": "4-Band Analytic",
"product": "http://cool-sat.com/catalog/products/analytic.json",
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
"roles": [
"data",
"analytic"
]
},
"thumbnail": {
"href": "http://cool-sat.com/catalog/CS3-20160503_132130_04/thumbnail.png",
"title": "Thumbnail",
"type": "image/png",
"roles": [
"thumbnail"
]
}
},
"bbox": [
-122.59750209,
37.48803556,
-122.2880486,
37.613537207
],
"stac_extensions": [],
"collection": "CS3"
}
28 changes: 28 additions & 0 deletions tests/v1/test_item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from copy import deepcopy
from typing import Any

from pystac import Item

from . import utils


def test_to_from_dict(sample_item_dict: dict[str, Any]) -> None:
param_dict = deepcopy(sample_item_dict)

utils.assert_to_from_dict(Item, param_dict)
item = Item.from_dict(param_dict)
assert item.id == "CS3-20160503_132131_05"

# test asset creation additional field(s)
assert (
item.assets["analytic"].extra_fields["product"]
== "http://cool-sat.com/catalog/products/analytic.json"
)
assert len(item.assets["thumbnail"].extra_fields) == 0

# test that the parameter is preserved
assert param_dict == sample_item_dict

# assert that the parameter is preserved regardless of preserve_dict
Item.from_dict(param_dict, preserve_dict=False)
assert param_dict == sample_item_dict
Loading