Skip to content

Commit

Permalink
ProjectionExtension v2 (proj:epsg -> proj:code) (#1287)
Browse files Browse the repository at this point in the history
* Update for projection extension v2 (proj:epsg -> proj:code)

* Add tests for old version of the extension

* add new fields config to fix summaries

* precommit touch up

* Rebuild cassettes

* Tidy up fields - remove dev changes

* Test errors

* Revert changes to docs notebook

---------

Co-authored-by: KeynesYouDigit <[email protected]>
  • Loading branch information
jsignell and KeynesYouDigIt authored Jan 14, 2025
1 parent ab747be commit 2261b55
Show file tree
Hide file tree
Showing 304 changed files with 10,325 additions and 9,531 deletions.
2 changes: 1 addition & 1 deletion pystac/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ def ext(self) -> AssetExt:
Example::
asset.ext.proj.epsg = 4326
asset.ext.proj.code = "EPSG:4326"
"""
from pystac.extensions.ext import AssetExt

Expand Down
27 changes: 19 additions & 8 deletions pystac/extensions/eo.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,19 +656,30 @@ def migrate(
]
del obj["properties"][f"eo:{field}"]

# eo:epsg became proj:epsg
# eo:epsg became proj:epsg in Projection Extension <2.0.0 and became
# proj:code in Projection Extension 2.0.0
eo_epsg = PREFIX + "epsg"
proj_epsg = projection.PREFIX + "epsg"
if eo_epsg in obj["properties"] and proj_epsg not in obj["properties"]:
obj["properties"][proj_epsg] = obj["properties"].pop(eo_epsg)
proj_code = projection.PREFIX + "code"
if (
eo_epsg in obj["properties"]
and proj_epsg not in obj["properties"]
and proj_code not in obj["properties"]
):
obj["stac_extensions"] = obj.get("stac_extensions", [])
if (
projection.ProjectionExtension.get_schema_uri()
not in obj["stac_extensions"]
if set(obj["stac_extensions"]).intersection(
projection.ProjectionExtensionHooks.pre_2
):
obj["stac_extensions"].append(
projection.ProjectionExtension.get_schema_uri()
obj["properties"][proj_epsg] = obj["properties"].pop(eo_epsg)
else:
obj["properties"][proj_code] = (
f"EPSG:{obj['properties'].pop(eo_epsg)}"
)
if not projection.ProjectionExtensionHooks().has_extension(obj):
obj["stac_extensions"].append(
projection.ProjectionExtension.get_schema_uri()
)

if not any(prop.startswith(PREFIX) for prop in obj["properties"]):
obj["stac_extensions"].remove(EOExtension.get_schema_uri())

Expand Down
8 changes: 8 additions & 0 deletions pystac/extensions/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import TYPE_CHECKING, Any

import pystac
from pystac.extensions.base import VERSION_REGEX
from pystac.serialization.identify import STACJSONDescription, STACVersionID

if TYPE_CHECKING:
Expand Down Expand Up @@ -43,6 +44,13 @@ def _get_stac_object_types(self) -> set[str]:
def get_object_links(self, obj: STACObject) -> list[str | pystac.RelType] | None:
return None

def has_extension(self, obj: dict[str, Any]) -> bool:
schema_startswith = VERSION_REGEX.split(self.schema_uri)[0] + "/"
return any(
uri.startswith(schema_startswith) or uri in self.prev_extension_ids
for uri in obj.get("stac_extensions", [])
)

def migrate(
self, obj: dict[str, Any], version: STACVersionID, info: STACJSONDescription
) -> None:
Expand Down
102 changes: 88 additions & 14 deletions pystac/extensions/projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,20 @@
SummariesExtension,
)
from pystac.extensions.hooks import ExtensionHooks
from pystac.serialization.identify import STACJSONDescription, STACVersionID

T = TypeVar("T", pystac.Item, pystac.Asset, pystac.ItemAssetDefinition)

SCHEMA_URI: str = "https://stac-extensions.github.io/projection/v1.1.0/schema.json"
SCHEMA_URI: str = "https://stac-extensions.github.io/projection/v2.0.0/schema.json"
SCHEMA_URIS: list[str] = [
"https://stac-extensions.github.io/projection/v1.0.0/schema.json",
"https://stac-extensions.github.io/projection/v1.1.0/schema.json",
SCHEMA_URI,
]
PREFIX: str = "proj:"

# Field names
CODE_PROP: str = PREFIX + "code"
EPSG_PROP: str = PREFIX + "epsg"
WKT2_PROP: str = PREFIX + "wkt2"
PROJJSON_PROP: str = PREFIX + "projjson"
Expand Down Expand Up @@ -65,7 +68,9 @@ class ProjectionExtension(

def apply(
self,
epsg: int | None,
*,
epsg: int | None = None,
code: str | None = None,
wkt2: str | None = None,
projjson: dict[str, Any] | None = None,
geometry: dict[str, Any] | None = None,
Expand All @@ -77,7 +82,10 @@ def apply(
"""Applies Projection extension properties to the extended Item.
Args:
epsg : REQUIRED. EPSG code of the datasource.
epsg : Code of the datasource. Example: 4326. One of ``code`` and
``epsg`` must be provided.
code : Code of the datasource. Example: "EPSG:4326". One of ``code`` and
``epsg`` must be provided.
wkt2 : WKT2 string representing the Coordinate Reference
System (CRS) that the ``geometry`` and ``bbox`` fields represent
projjson : PROJJSON dict representing the
Expand All @@ -96,7 +104,15 @@ def apply(
transform : The affine transformation coefficients for
the default grid
"""
self.epsg = epsg
if epsg is not None and code is not None:
raise KeyError(
"Only one of the options ``code`` and ``epsg`` should be specified."
)
elif epsg:
self.epsg = epsg
else:
self.code = code

self.wkt2 = wkt2
self.projjson = projjson
self.geometry = geometry
Expand All @@ -117,11 +133,33 @@ def epsg(self) -> int | None:
It should also be set to ``None`` if a CRS exists, but for which there is no
valid EPSG code.
"""
return self._get_property(EPSG_PROP, int)
if self.code is not None and self.code.startswith("EPSG:"):
return int(self.code.replace("EPSG:", ""))
return None

@epsg.setter
def epsg(self, v: int | None) -> None:
self._set_property(EPSG_PROP, v, pop_if_none=False)
if v is None:
self.code = None
else:
self.code = f"EPSG:{v}"

@property
def code(self) -> str | None:
"""Get or set the code of the datasource.
Added in version 2.0.0 of this extension replacing "proj:epsg".
Projection codes are identified by a string. The `proj <https://proj.org/>`_
library defines projections using "authority:code", e.g., "EPSG:4326" or
"IAU_2015:30100". Different projection authorities may define different
string formats.
"""
return self._get_property(CODE_PROP, str)

@code.setter
def code(self, v: int | None) -> None:
self._set_property(CODE_PROP, v, pop_if_none=False)

@property
def wkt2(self) -> str | None:
Expand Down Expand Up @@ -168,13 +206,13 @@ def crs_string(self) -> str | None:
This string can be used to feed, e.g., ``rasterio.crs.CRS.from_string``.
The string is determined by the following heuristic:
1. If an EPSG code is set, return "EPSG:{code}", else
1. If a code is set, return the code string, else
2. If wkt2 is set, return the WKT string, else,
3. If projjson is set, return the projjson as a string, else,
4. Return None
"""
if self.epsg:
return f"EPSG:{self.epsg}"
if self.code:
return self.code
elif self.wkt2:
return self.wkt2
elif self.projjson:
Expand All @@ -189,7 +227,7 @@ def geometry(self) -> dict[str, Any] | None:
This dict should be formatted according the Polygon object format specified in
`RFC 7946, sections 3.1.6 <https://tools.ietf.org/html/rfc7946>`_,
except not necessarily in EPSG:4326 as required by RFC7946. Specified based on
the ``epsg``, ``projjson`` or ``wkt2`` fields (not necessarily EPSG:4326).
the ``code``, ``projjson`` or ``wkt2`` fields (not necessarily EPSG:4326).
Ideally, this will be represented by a Polygon with five coordinates, as the
item in the asset data CRS should be a square aligned to the original CRS grid.
"""
Expand All @@ -204,7 +242,7 @@ def bbox(self) -> list[float] | None:
"""Get or sets the bounding box of the assets represented by this item in the
asset data CRS.
Specified as 4 or 6 coordinates based on the CRS defined in the ``epsg``,
Specified as 4 or 6 coordinates based on the CRS defined in the ``code``,
``projjson`` or ``wkt2`` properties. First two numbers are coordinates of the
lower left corner, followed by coordinates of upper right corner, e.g.,
``[west, south, east, north]``, ``[xmin, ymin, xmax, ymax]``,
Expand Down Expand Up @@ -382,16 +420,32 @@ class SummariesProjectionExtension(SummariesExtension):
defined in the :stac-ext:`Projection Extension <projection>`.
"""

@property
def code(self) -> list[str] | None:
"""Get or sets the summary of :attr:`ProjectionExtension.code` values
for this Collection.
"""
return self.summaries.get_list(CODE_PROP)

@code.setter
def code(self, v: list[str] | None) -> None:
self._set_summary(CODE_PROP, v)

@property
def epsg(self) -> list[int] | None:
"""Get or sets the summary of :attr:`ProjectionExtension.epsg` values
"""Get the summary of :attr:`ProjectionExtension.epsg` values
for this Collection.
"""
return self.summaries.get_list(EPSG_PROP)
if self.code is None:
return None
return [int(code.replace("EPSG:", "")) for code in self.code if "EPSG:" in code]

@epsg.setter
def epsg(self, v: list[int] | None) -> None:
self._set_summary(EPSG_PROP, v)
if v is None:
self.code = None
else:
self.code = [f"EPSG:{epsg}" for epsg in v]


class ProjectionExtensionHooks(ExtensionHooks):
Expand All @@ -401,7 +455,27 @@ class ProjectionExtensionHooks(ExtensionHooks):
"projection",
*[uri for uri in SCHEMA_URIS if uri != SCHEMA_URI],
}
pre_2 = {
"proj",
"projection",
"https://stac-extensions.github.io/projection/v1.0.0/schema.json",
"https://stac-extensions.github.io/projection/v1.1.0/schema.json",
}
stac_object_types = {pystac.STACObjectType.ITEM}

def migrate(
self, obj: dict[str, Any], version: STACVersionID, info: STACJSONDescription
) -> None:
if not self.has_extension(obj):
return

# proj:epsg moved to proj:code
if "proj:epsg" in obj["properties"]:
epsg = obj["properties"]["proj:epsg"]
obj["properties"]["proj:code"] = f"EPSG:{epsg}"
del obj["properties"]["proj:epsg"]

super().migrate(obj, version, info)


PROJECTION_EXTENSION_HOOKS: ExtensionHooks = ProjectionExtensionHooks()
2 changes: 1 addition & 1 deletion pystac/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ def ext(self) -> ItemExt:
Example::
item.ext.proj.epsg = 4326
item.ext.proj.code = "EPSG:4326"
"""
from pystac.extensions.ext import ItemExt

Expand Down
2 changes: 1 addition & 1 deletion pystac/static/fields-normalized.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion scripts/pull-static
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

set -e

VERSION="1.0.0-rc.1"
VERSION="1.5.0" #v1.5.0-beta.2 should work or no??
SRC="https://cdn.jsdelivr.net/npm/@radiantearth/stac-fields@$VERSION/fields-normalized.json"
HERE=$(dirname "$0")
DEST=$(dirname "$HERE")/pystac/static/fields-normalized.json
Expand Down
Loading

0 comments on commit 2261b55

Please sign in to comment.