from __future__ import annotations
import os
from copy import copy
from html import escape
from typing import TYPE_CHECKING, Any, TypeVar
import pystac
from pystac.html.jinja_env import get_jinja_env
from pystac.utils import (
HREF as HREF,
)
from pystac.utils import (
is_absolute_href,
make_absolute_href,
make_posix_style,
make_relative_href,
)
if TYPE_CHECKING:
from pystac.catalog import Catalog
from pystac.collection import Collection
from pystac.extensions.ext import LinkExt
from pystac.item import Item
from pystac.stac_object import STACObject
PathLike = os.PathLike[str]
else:
PathLike = os.PathLike
L = TypeVar("L", bound="Link")
#: Hierarchical links provide structure to STAC catalogs.
HIERARCHICAL_LINKS = [
pystac.RelType.ROOT,
pystac.RelType.CHILD,
pystac.RelType.PARENT,
pystac.RelType.COLLECTION,
pystac.RelType.ITEM,
pystac.RelType.ITEMS,
]
[docs]class Link(PathLike):
"""A link connects a :class:`~pystac.STACObject` to another entity.
The target of a link can be either another STACObject, or
an HREF. When serialized, links always refer to the HREF of the target.
Links are lazily deserialized - this is, when you read in a link, it does
not automatically load in the STACObject that is the target
(if the link is pointing to a STACObject). When a user is crawling
through a catalog or when additional metadata is required, PySTAC uses the
:func:`Link.resolve_stac_object <pystac.Link.resolve_stac_object>` method
to load in and deserialize STACObjects. This mechanism is used within
the PySTAC codebase and normally does not need to be considered by the user -
ideally the lazy deserialization of STACObjects is transparent to clients of PySTAC.
Args:
rel : The relation of the link (e.g. 'child', 'item'). Registered rel Types
are preferred. See :class:`~pystac.RelType` for common media types.
target : The target of the link. If the link is
unresolved, or the link is to something that is not a STACObject,
the target is an HREF. If resolved, the target is a STACObject.
media_type : Optional description of the media type. Registered Media Types
are preferred. See :class:`~pystac.MediaType` for common media types.
title : Optional title for this link.
extra_fields : Optional, additional fields for this link. This is used
by extensions as a way to serialize and deserialize properties on link
object JSON.
"""
rel: str | pystac.RelType
"""The relation of the link (e.g. 'child', 'item'). Registered rel Types are
preferred. See :class:`~pystac.RelType` for common media types."""
media_type: str | pystac.MediaType | None
"""Optional description of the media type. Registered Media Types are preferred.
See :class:`~pystac.MediaType` for common media types."""
extra_fields: dict[str, Any]
"""Optional, additional fields for this link. This is used by extensions as a
way to serialize and deserialize properties on link object JSON."""
owner: STACObject | None
"""The owner of this link. The link will use its owner's root catalog
:class:`~pystac.resolved_object_cache.ResolvedObjectCache` to resolve objects, and
will create absolute HREFs from relative HREFs against the owner's self HREF."""
_target_href: str | None
_target_object: STACObject | None
_title: str | None
def __init__(
self,
rel: str | pystac.RelType,
target: str | STACObject,
media_type: str | pystac.MediaType | None = None,
title: str | None = None,
extra_fields: dict[str, Any] | None = None,
) -> None:
self.rel = rel
if isinstance(target, str):
if rel == pystac.RelType.SELF:
self._target_href = make_absolute_href(target)
else:
self._target_href = make_posix_style(target)
self._target_object = None
else:
self._target_href = None
self._target_object = target
self.media_type = media_type
self.title = title
self.extra_fields = extra_fields or {}
self.owner = None
[docs] def set_owner(self, owner: STACObject | None) -> Link:
"""Sets the owner of this link.
Args:
owner: The owner of this link. Pass None to clear.
"""
self.owner = owner
return self
@property
def title(self) -> str | None:
"""Optional title for this link. If not provided during instantiation, this will
attempt to get the title from the STAC object that the link references."""
if self._title is not None:
return self._title
if self._target_object is not None and isinstance(
self._target_object, pystac.Catalog
):
return self._target_object.title
return None
@title.setter
def title(self, v: str | None) -> None:
self._title = v
@property
def href(self) -> str:
"""Returns the HREF for this link.
If the href is None, this will throw an exception.
Use get_href if there may not be an href.
"""
result = self.get_href()
if result is None:
raise ValueError(f"{self} does not have an HREF set.")
return result
[docs] def get_href(self, transform_href: bool = True) -> str | None:
"""Gets the HREF for this link.
Args:
transform_href: If True, transform the HREF based on the type of
catalog the owner belongs to (if any). I.e. if the link owner
belongs to a root catalog that is RELATIVE_PUBLISHED or SELF_CONTAINED,
the HREF will be transformed to be relative to the catalog root
if this is a hierarchical link relation.
Returns:
str: Returns this link's HREF. If there is an owner of the link and
the root catalog (if there is one) is of type RELATIVE_PUBLISHED,
then the HREF returned will be relative.
In all other cases, this method will return an absolute HREF.
"""
# get the self href
if self._target_object:
href = self._target_object.get_self_href()
else:
href = self._target_href
if (
transform_href
and href
and is_absolute_href(href)
and self.owner
and self.owner.get_root()
):
root = self.owner.get_root()
rel_links = [
*HIERARCHICAL_LINKS,
*pystac.EXTENSION_HOOKS.get_extended_object_links(self.owner),
]
# if a hierarchical link with an owner and root, and relative catalog
if root and root.is_relative():
if self.rel in rel_links or root.target_in_hierarchy(self.target):
owner_href = self.owner.get_self_href()
if owner_href is not None:
href = make_relative_href(href, owner_href)
return href
@property
def absolute_href(self) -> str:
"""Returns the absolute HREF for this link.
If the href is None, this will throw an exception.
Use get_absolute_href if there may not be an href set.
"""
result = self.get_absolute_href()
if result is None:
raise ValueError(f"{self} does not have an HREF set.")
return result
[docs] def get_absolute_href(self) -> str | None:
"""Gets the absolute href for this link, if possible.
Returns:
str: Returns this link's HREF. It attempts to derive an absolute HREF
from this link; however, if the link is relative, has no owner,
and has an unresolved target, this will return a relative HREF.
"""
if self._target_object:
href = self._target_object.get_self_href()
else:
href = self._target_href
if href is not None and self.owner is not None:
href = make_absolute_href(href, self.owner.get_self_href())
return href
@property
def target(self) -> str | STACObject:
"""The target of the link. If the link is unresolved, or the link is to
something that is not a STACObject, the target is an HREF. If resolved, the
target is a STACObject."""
if self._target_object:
return self._target_object
elif self._target_href:
return self._target_href
else:
raise ValueError("No target defined for link.")
@target.setter
def target(self, target: str | STACObject) -> None:
"""Sets this link's target to a string or a STAC object."""
if isinstance(target, str):
self._target_href = target
self._target_object = None
else:
self._target_href = None
self._target_object = target
[docs] def get_target_str(self) -> str | None:
"""Returns this link's target as a string.
If a string href was provided, returns that. If not, tries to resolve
the self link of the target object.
"""
if self._target_href:
return self._target_href
elif self._target_object:
return self._target_object.get_self_href()
else:
return None
[docs] def has_target_href(self) -> bool:
"""Returns true if this link has a string href in its target information."""
return self._target_href is not None
def __fspath__(self) -> str:
return self.absolute_href
def __repr__(self) -> str:
return f"<Link rel={self.rel} target={self.target}>"
def _repr_html_(self) -> str:
jinja_env = get_jinja_env()
if jinja_env:
template = jinja_env.get_template("JSON.jinja2")
return str(template.render(dict=self.to_dict()))
else:
return escape(repr(self))
[docs] def resolve_stac_object(self, root: Catalog | None = None) -> Link:
"""Resolves a STAC object from the HREF of this link, if the link is not
already resolved.
Args:
root : Optional root of the catalog for this link.
If provided, the root's resolved object cache is used to search for
previously resolved instances of the STAC object.
"""
if self._target_object:
pass
elif self._target_href:
target_href = self._target_href
# If it's a relative link, base it off the parent.
if not is_absolute_href(target_href):
if self.owner is None:
raise pystac.STACError(
"Relative path {} encountered "
"without owner or start_href.".format(target_href)
)
start_href = self.owner.get_self_href()
if start_href is None:
raise pystac.STACError(
"Relative path {} encountered "
'without owner "self" link set.'.format(target_href)
)
target_href = make_absolute_href(target_href, start_href)
obj = None
stac_io: pystac.StacIO | None = None
if root is not None:
obj = root._resolved_objects.get_by_href(target_href)
stac_io = root._stac_io
if obj is None:
if stac_io is None:
if self.owner is not None:
if isinstance(self.owner, pystac.Catalog):
stac_io = self.owner._stac_io
elif self.rel != pystac.RelType.ROOT:
owner_root = self.owner.get_root()
if owner_root is not None:
stac_io = owner_root._stac_io
if stac_io is None:
stac_io = pystac.StacIO.default()
obj = stac_io.read_stac_object(target_href, root=root)
obj.set_self_href(target_href)
if root is not None:
obj = root._resolved_objects.get_or_cache(obj)
obj.set_root(root)
self._target_object = obj
else:
raise ValueError("Cannot resolve STAC object without a target")
if (
self.owner
and self.rel in [pystac.RelType.CHILD, pystac.RelType.ITEM]
and isinstance(self.owner, pystac.Catalog)
):
assert self._target_object
# Do nothing if the object wants to keep its parent
# https://github.com/stac-utils/pystac/issues/1116
if self._target_object._allow_parent_to_override_href:
self._target_object.set_parent(self.owner)
return self
[docs] def is_resolved(self) -> bool:
"""Determines if the link's target is a resolved STACObject.
Returns:
bool: True if this link is resolved.
"""
return self._target_object is not None
[docs] def is_hierarchical(self) -> bool:
"""Returns true if this link's rel type is hierarchical.
Hierarchical links are used to build relationships in STAC, e.g.
"parent", "child", "item", etc. For a complete list of hierarchical
relation types, see :py:const:`HIERARCHICAL_LINKS`.
Returns:
bool: True if the link's rel type is hierarchical.
"""
return self.rel in HIERARCHICAL_LINKS
[docs] def to_dict(self, transform_href: bool = True) -> dict[str, Any]:
"""Returns this link as a dictionary.
Args:
transform_href : If ``True``, transform the HREF based on the type of
catalog the owner belongs to (if any). I.e. if the link owner
belongs to a root catalog that is RELATIVE_PUBLISHED or SELF_CONTAINED,
the HREF will be transformed to be relative to the catalog root
if this is a hierarchical link relation.
Returns:
dict : A serialization of the Link.
"""
d: dict[str, Any] = {
"rel": str(self.rel),
"href": self.get_href(transform_href=transform_href),
}
if self.media_type is not None:
d["type"] = str(self.media_type)
if self.title is not None:
d["title"] = self.title
for k, v in self.extra_fields.items():
d[k] = v
return d
[docs] def clone(self) -> Link:
"""Clones this link.
This makes a copy of all link information, but does not clone a STACObject
if one is the target of this link.
Returns:
Link: The cloned link.
"""
cls = self.__class__
return cls(
rel=self.rel,
target=self.target,
media_type=self.media_type,
title=self.title,
)
[docs] @classmethod
def from_dict(cls: type[L], d: dict[str, Any]) -> L:
"""Deserializes a Link from a dict.
Args:
d : The dict that represents the Link in JSON
Returns:
Link: Link instance constructed from the dict.
"""
d = copy(d)
rel = d.pop("rel")
href = d.pop("href")
media_type = d.pop("type", None)
title = d.pop("title", None)
extra_fields = None
if any(d):
extra_fields = d
return cls(
rel=rel,
target=href,
media_type=media_type,
title=title,
extra_fields=extra_fields,
)
[docs] @classmethod
def root(cls: type[L], c: Catalog) -> L:
"""Creates a link to a root Catalog or Collection."""
return cls(pystac.RelType.ROOT, c, media_type=pystac.MediaType.JSON)
[docs] @classmethod
def parent(cls: type[L], c: Catalog) -> L:
"""Creates a link to a parent Catalog or Collection."""
return cls(pystac.RelType.PARENT, c, media_type=pystac.MediaType.JSON)
[docs] @classmethod
def collection(cls: type[L], c: Collection) -> L:
"""Creates a link to an item's Collection."""
return cls(pystac.RelType.COLLECTION, c, media_type=pystac.MediaType.JSON)
[docs] @classmethod
def self_href(cls: type[L], href: HREF) -> L:
"""Creates a self link to a file's location."""
href_str = str(os.fspath(href))
return cls(pystac.RelType.SELF, href_str, media_type=pystac.MediaType.JSON)
[docs] @classmethod
def child(cls: type[L], c: Catalog, title: str | None = None) -> L:
"""Creates a link to a child Catalog or Collection."""
return cls(
pystac.RelType.CHILD, c, title=title, media_type=pystac.MediaType.JSON
)
[docs] @classmethod
def item(cls: type[L], item: Item, title: str | None = None) -> L:
"""Creates a link to an Item."""
return cls(
pystac.RelType.ITEM, item, title=title, media_type=pystac.MediaType.JSON
)
[docs] @classmethod
def derived_from(cls: type[L], item: Item | str, title: str | None = None) -> L:
"""Creates a link to a derived_from Item."""
return cls(
pystac.RelType.DERIVED_FROM,
item,
title=title,
media_type=pystac.MediaType.JSON,
)
[docs] @classmethod
def canonical(
cls: type[L],
item_or_collection: Item | Collection,
title: str | None = None,
) -> L:
"""Creates a canonical link to an Item or Collection."""
return cls(
pystac.RelType.CANONICAL,
item_or_collection,
title=title,
media_type=pystac.MediaType.JSON,
)
@property
def ext(self) -> LinkExt:
"""Accessor for extension classes on this link
Example::
link.ext.file.size = 8675309
"""
from pystac.extensions.ext import LinkExt
return LinkExt(stac_object=self)