Source code for descarteslabs.catalog.catalog_base

# Copyright 2018-2024 Descartes Labs.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json
import urllib.parse
from functools import wraps
from types import MethodType

from descarteslabs.exceptions import NotFoundError

from ..client.deprecation import deprecate
from ..common.collection import Collection
from .attributes import (
    AttributeEqualityMixin,
    AttributeMeta,
    AttributeValidationError,
    CatalogObjectReference,
    DocumentState,
    ExtraPropertiesAttribute,
    ListAttribute,
    Timestamp,
    TypedAttribute,
)
from .catalog_client import CatalogClient, HttpRequestMethod
from .search import Search


[docs]class DeletedObjectError(Exception): """Indicates that an action cannot be performed. Raised when some action cannot be performed because the catalog object has been deleted from the Descartes Labs catalog using the delete method (e.g. :py:meth:`Product.delete`). """ pass
[docs]class UnsavedObjectError(Exception): """Indicate that an action cannot be performed. Raised when trying to delete an object that hasn't been saved. """ pass
def check_deleted(f): @wraps(f) def wrapper(self, *args, **kwargs): if self.state == DocumentState.DELETED: raise DeletedObjectError("This catalog object has been deleted.") try: return f(self, *args, **kwargs) except NotFoundError as e: self._deleted = True raise DeletedObjectError( "{} instance with id {} has been deleted".format( self.__class__.__name__, self.id ) ).with_traceback(e.__traceback__) from None return wrapper def check_derived(f): @wraps(f) def wrapper(cls, *args, **kwargs): if cls._url is None: raise TypeError( "This method is only available for a derived class of 'CatalogObject'" ) return f(cls, *args, **kwargs) return wrapper def _new_abstract_class(cls, abstract_cls): if cls is abstract_cls: raise TypeError( "You can only instantiate a derived class of '{}'".format( abstract_cls.__name__ ) ) return super(abstract_cls, cls).__new__(cls) # This lets us have a class method and an instance method with the same name, but # different signatures and implementation. # see https://stackoverflow.com/questions/28237955/same-name-for-classmethod-and-instancemethod class hybridmethod: def __init__(self, fclass, finstance=None, doc=None): self.fclass = fclass self.finstance = finstance self.__doc__ = doc or fclass.__doc__ # support use on abstract base classes self.__isabstractmethod__ = bool(getattr(fclass, "__isabstractmethod__", False)) def classmethod(self, fclass): return type(self)(fclass, self.finstance, None) def instancemethod(self, finstance): return type(self)(self.fclass, finstance, self.__doc__) def __get__(self, instance, cls): if instance is None or self.finstance is None: # either bound to the class, or no instance method available return self.fclass.__get__(cls, None) return self.finstance.__get__(instance, cls) class CatalogObjectMeta(AttributeMeta): def __new__(cls, name, bases, attrs): new_cls = super(CatalogObjectMeta, cls).__new__(cls, name, bases, attrs) if new_cls._doc_type: new_cls._model_classes_by_type_and_derived_type[ (new_cls._doc_type, new_cls._derived_type) ] = new_cls return new_cls class CatalogObjectBase(AttributeEqualityMixin, metaclass=CatalogObjectMeta): """A base class for all representations of top level objects in the Catalog API.""" # The following can be overridden by subclasses to customize behavior: # JSONAPI type for this model (required) _doc_type = None # Path added to the base URL for a list request of this model (required) _url = None # List of related objects to include in read requests _default_includes = [] # The derived type of this class _derived_type = None # Attribute to use to determine the derived type of an instance _derived_type_switch = None _model_classes_by_type_and_derived_type = {} # Type returned by collect() on the corresponding Search object _collection_type = Collection id = TypedAttribute( str, mutable=False, serializable=False, doc="""str, immutable: A unique identifier for this object. Note that if you pass a string that does not begin with your Descartes Labs user organization ID, it will be prepended to your `id` with a ``:`` as separator. If you are not part of an organization, your user ID is used. Once set, it cannot be changed. """, ) created = Timestamp( readonly=True, doc="""datetime, readonly: The point in time this object was created. *Filterable, sortable*. """, ) modified = Timestamp( readonly=True, doc="""datetime, readonly: The point in time this object was last modified. *Filterable, sortable*. """, ) v1_properties = TypedAttribute( dict, mutable=False, serializable=False, readonly=True, ) def __new__(cls, *args, **kwargs): return _new_abstract_class(cls, CatalogObjectBase) def __init__(self, **kwargs): self._client = kwargs.pop("client", None) or CatalogClient.get_default_client() self._attributes = {} self._modified = set() self._deleted = False self._initialize( id=kwargs.pop("id", None), saved=kwargs.pop("_saved", False), relationships=kwargs.pop("_relationships", None), related_objects=kwargs.pop("_related_objects", None), **kwargs, ) def __del__(self): for attr_type in self._attribute_types.values(): attr_type.__delete__(self, validate=False) def _clear_attributes(self): self._mapping_attribute_instances = {} self._clear_modified_attributes() # This only applies to top-level attributes sticky_attributes = {} for name, value in self._attributes.items(): attribute_type = self._attribute_types.get(name) if attribute_type._sticky: sticky_attributes[name] = value self._attributes = sticky_attributes def _initialize( self, id=None, saved=False, relationships=None, related_objects=None, deleted=False, **kwargs, ): self._clear_attributes() self._saved = saved self._deleted = deleted # This is an immutable attribute; can only be set once if id: self.id = id for name, val in kwargs.items(): # Only silently ignore unknown attributes if data came from service attribute_definition = ( self._attribute_types.get(name) if saved else self._get_attribute_type(name) ) if attribute_definition is not None: attribute_definition.__set__(self, val, validate=not saved) for name, t in self._reference_attribute_types.items(): id_value = kwargs.get(t.id_field) if id_value is not None: object_value = kwargs.get(name) if object_value and object_value.id != id_value: message = ( "Conflicting related object reference: '{}' was '{}' " "but '{}' was '{}'" ).format(t.id_field, id_value, name, object_value.id) raise AttributeValidationError(message) if related_objects: related_object = related_objects.get( (t.reference_class._doc_type, id_value) ) if related_object is not None: t.__set__(self, related_object, validate=not saved) if saved: self._clear_modified_attributes() def __repr__(self): name = getattr(self, "name", None) if name is None: name = "" elif isinstance(name, bytes): name = name.decode() sections = [ # Document type and ID "{}: {}\n id: {}".format(self.__class__.__name__, name, self.id) ] # related objects and their ids for name in sorted(self._reference_attribute_types): t = self._reference_attribute_types[name] # as a temporary hack for image upload, handle missing image_id field sections.append(" {}: {}".format(name, getattr(self, t.id_field, None))) if self.created: sections.append(" created: {:%c}".format(self.created)) if self.state == DocumentState.DELETED: sections.append("* Deleted from the Descartes Labs catalog.") elif self.state != DocumentState.SAVED: sections.append( "* Not up-to-date in the Descartes Labs catalog. Call `.save()` to save or update this record." ) return "\n".join(sections) def __eq__(self, other): if ( not isinstance(other, self.__class__) or self.id != other.id or self.state != other.state ): return False return super(CatalogObjectBase, self).__eq__(other) def __setattr__(self, name, value): if not (name.startswith("_") or isinstance(value, MethodType)): # Make sure it's a proper attribute self._get_attribute_type(name) super(CatalogObjectBase, self).__setattr__(name, value) @property def is_modified(self): """bool: Whether any attributes were changed (see `state`). ``True`` if any of the attribute values changed since the last time this catalog object was retrieved or saved. ``False`` otherwise. Note that assigning an identical value does not affect the state. """ return bool(self._modified) @classmethod def _get_attribute_type(cls, name): try: return cls._attribute_types[name] except KeyError: raise AttributeError("{} has no attribute {}".format(cls.__name__, name)) @classmethod def _get_model_class(cls, serialized_object): class_type = serialized_object["type"] klass = cls._model_classes_by_type_and_derived_type.get((class_type, None)) if klass._derived_type_switch: derived_type = serialized_object["attributes"][klass._derived_type_switch] klass = cls._model_classes_by_type_and_derived_type.get( (class_type, derived_type) ) return klass @classmethod def _serialize_filter_attribute(cls, name, value): """Serialize a single value for a filter. Allow the given value to be serialized using the serialization logic of the given attribute. This method should only be used to serialize a filter value. Parameters ---------- name : str The name of the attribute used for serialization logic. value : object The value to be serialized. Returns ------- name : str The name to use in the serialized filter value : str The serialized value Raises ------ AttributeValidationError If the attribute is not serializable. """ attribute_type = cls._get_attribute_type(name) if isinstance(attribute_type, ListAttribute): # The type is contained in the list attribute_type = attribute_type._attribute_type if isinstance(attribute_type, CatalogObjectReference): # This is a little tricky... If the value is an instance containing # `id`, the name was already updated by the Expression to have `_id` # appended to it, and the value will be converted to a string below. # But if the value is a string, this hasn't happened yet and we need # to update the name... if value is None or isinstance(value, str): return (attribute_type.id_field, value) return (name, attribute_type.serialize(value)) def _set_modified(self, attr_name, changed=True, validate=True): # Verify it is allowed to to be set attr = self._get_attribute_type(attr_name) if validate: if attr._readonly: raise AttributeValidationError( "Can't set '{}' because it is a readonly attribute".format( attr_name ) ) if not attr._mutable and attr_name in self._attributes: raise AttributeValidationError( "Can't set '{}' because it is an immutable attribute".format( attr_name ) ) if changed: self._modified.add(attr_name) def _serialize(self, attrs, jsonapi_format=False): serialized = {} for name in attrs: value = self._attributes[name] attribute_type = self._get_attribute_type(name) if attribute_type._serializable: serialized[name] = attribute_type.serialize( value, jsonapi_format=jsonapi_format ) return serialized @check_deleted def update(self, ignore_errors=False, **kwargs): """Update multiple attributes at once using the given keyword arguments. Parameters ---------- ignore_errors : bool, optional ``False`` by default. When set to ``True``, it will suppress `AttributeValidationError` and `AttributeError`. Any given attribute that causes one of these two exceptions will be ignored, all other attributes will be set to the given values. Raises ------ AttributeValidationError If one or more of the attributes being updated are immutable. AttributeError If one or more of the attributes are not part of this catalog object. DeletedObjectError If this catalog object was deleted. """ original_values = dict(self._attributes) original_modified = set(self._modified) for name, val in kwargs.items(): try: # A non-existent attribute will raise an AttributeError attribute_definition = self._get_attribute_type(name) # A bad value will raise an AttributeValidationError attribute_definition.__set__(self, val) except (AttributeError, AttributeValidationError): if ignore_errors: pass else: self._attributes = original_values self._modified = original_modified raise def serialize(self, modified_only=False, jsonapi_format=False): """Serialize the catalog object into json. Parameters ---------- modified_only : bool, optional Whether only modified attributes should be serialized. ``False`` by default. If set to ``True``, only those attributes that were modified since the last time the catalog object was retrieved or saved will be included. jsonapi_format : bool, optional Whether to use the ``data`` element for catalog objects. ``False`` by default. When set to ``False``, the serialized data will directly contain the attributes of the catalog object. If set to ``True``, the serialized data will follow the exact JSONAPI with a top-level ``data`` element which contains ``id``, ``type``, and ``attributes``. The latter will contain the attributes of the catalog object. """ keys = self._modified if modified_only else self._attributes.keys() attributes = self._serialize(keys, jsonapi_format=jsonapi_format) if jsonapi_format: return self._client.jsonapi_document(self._doc_type, attributes, self.id) else: return attributes def _clear_modified_attributes(self): self._modified = set() @property def state(self): """DocumentState: The state of this catalog object.""" if self._deleted: return DocumentState.DELETED if self._saved is False: return DocumentState.UNSAVED elif self.is_modified: return DocumentState.MODIFIED else: return DocumentState.SAVED @classmethod def get(cls, id, client=None, request_params=None, headers=None): """Get an existing object from the Descartes Labs catalog. If the Descartes Labs catalog object is found, it will be returned in the `~descarteslabs.catalog.DocumentState.SAVED` state. Subsequent changes will put the instance in the `~descarteslabs.catalog.DocumentState.MODIFIED` state, and you can use :py:meth:`save` to commit those changes and update the Descartes Labs catalog object. Also see the example for :py:meth:`save`. For bands, if you request a specific band type, for example :meth:`SpectralBand.get`, you will only receive that type. Use :meth:`Band.get` to receive any type. Parameters ---------- id : str The id of the object you are requesting. client : CatalogClient, optional A `CatalogClient` instance to use for requests to the Descartes Labs catalog. The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will be used if not set. Returns ------- :py:class:`~descarteslabs.catalog.CatalogObject` or None The object you requested, or ``None`` if an object with the given `id` does not exist in the Descartes Labs catalog. Raises ------ ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError :ref:`Spurious exception <network_exceptions>` that can occur during a network request. """ try: data, related_objects = cls._send_data( method=HttpRequestMethod.GET, id=id, client=client, request_params=request_params, headers=headers, ) except NotFoundError: return None model_class = cls._get_model_class(data) if not issubclass(model_class, cls): return None return model_class( id=data["id"], client=client, _saved=True, _relationships=data.get("relationships"), _related_objects=related_objects, **data["attributes"], ) @classmethod def get_or_create( cls, id, client=None, request_params=None, headers=None, **kwargs ): """Get an existing object from the Descartes Labs catalog or create a new object. If the Descartes Labs catalog object is found, and the remainder of the arguments do not differ from the values in the retrieved instance, it will be returned in the `~descarteslabs.catalog.DocumentState.SAVED` state. If the Descartes Labs catalog object is found, and the remainder of the arguments update one or more values in the instance, it will be returned in the `~descarteslabs.catalog.DocumentState.MODIFIED` state. If the Descartes Labs catalog object is not found, it will be created and the state will be `~descarteslabs.catalog.DocumentState.UNSAVED`. Also see the example for :py:meth:`save`. Parameters ---------- id : str The id of the object you are requesting. client : CatalogClient, optional A `CatalogClient` instance to use for requests to the Descartes Labs catalog. The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will be used if not set. kwargs : dict, optional With the exception of readonly attributes (`created`, `modified`), any attribute of a catalog object can be set as a keyword argument (Also see `ATTRIBUTES`). Returns ------- :py:class:`~descarteslabs.catalog.CatalogObject` The requested catalog object that was retrieved or created. """ obj = cls.get(id, client=client, request_params=request_params, headers=headers) if obj is None: obj = cls(id=id, client=client, **kwargs) else: obj.update(**kwargs) return obj @classmethod def get_many( cls, ids, ignore_missing=False, client=None, request_params=None, headers=None ): """Get existing objects from the Descartes Labs catalog. All returned Descartes Labs catalog objects will be in the `~descarteslabs.catalog.DocumentState.SAVED` state. Also see :py:meth:`get`. For bands, if you request a specific band type, for example :meth:`SpectralBand.get_many`, you will only receive that type. Use :meth:`Band.get_many` to receive any type. Parameters ---------- ids : list(str) A list of identifiers for the objects you are requesting. ignore_missing : bool, optional Whether to raise a `~descarteslabs.exceptions.NotFoundError` exception if any of the requested objects are not found in the Descartes Labs catalog. ``False`` by default which raises the exception. client : CatalogClient, optional A `CatalogClient` instance to use for requests to the Descartes Labs catalog. The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will be used if not set. Returns ------- list(:py:class:`~descarteslabs.catalog.CatalogObject`) List of the objects you requested in the same order. Raises ------ NotFoundError If any of the requested objects do not exist in the Descartes Labs catalog and `ignore_missing` is ``False``. ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError :ref:`Spurious exception <network_exceptions>` that can occur during a network request. """ if not isinstance(ids, list) or any(not isinstance(id_, str) for id_ in ids): raise TypeError("ids must be a list of strings") id_filter = {"name": "id", "op": "eq", "val": ids} raw_objects, related_objects = cls._send_data( method=HttpRequestMethod.PUT, client=client, json={"filter": json.dumps([id_filter], separators=(",", ":"))}, request_params=request_params, headers=headers, ) if not ignore_missing: received_ids = set(obj["id"] for obj in raw_objects) missing_ids = set(ids) - received_ids if len(missing_ids) > 0: raise NotFoundError( "Objects not found for ids: {}".format(", ".join(missing_ids)) ) objects = [ model_class( id=obj["id"], client=client, _saved=True, _relationships=obj.get("relationships"), _related_objects=related_objects, **obj["attributes"], ) for obj in raw_objects for model_class in (cls._get_model_class(obj),) if issubclass(model_class, cls) ] return objects @classmethod @check_derived def exists(cls, id, client=None, headers=None): """Checks if an object exists in the Descartes Labs catalog. Parameters ---------- id : str The id of the object. client : CatalogClient, optional A `CatalogClient` instance to use for requests to the Descartes Labs catalog. The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will be used if not set. Returns ------- bool Returns ``True`` if the given ``id`` represents an existing object in the Descartes Labs catalog and ``False`` if not. Raises ------ ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError :ref:`Spurious exception <network_exceptions>` that can occur during a network request. """ client = client or CatalogClient.get_default_client() r = None try: r = client.session.head(cls._url + "/" + id, headers=headers) except NotFoundError: return False return r and r.ok @classmethod @check_derived def search(cls, client=None, request_params=None, headers=None): """A search query for all objects of the type this class represents. Parameters ---------- client : CatalogClient, optional A `CatalogClient` instance to use for requests to the Descartes Labs catalog. The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will be used if not set. Returns ------- Search An instance of the :py:class:`~descarteslabs.catalog.Search` class. Example ------- >>> search = Product.search().limit(10) # doctest: +SKIP >>> for result in search: # doctest: +SKIP print(result.name) # doctest: +SKIP """ return Search( cls, client=client, request_params=request_params, headers=headers ) @check_deleted @deprecate(renamed={"extra_attributes": "request_params"}) def save(self, request_params=None, headers=None): """Saves this object to the Descartes Labs catalog. If this instance was created using the constructor, it will be in the `~descarteslabs.catalog.DocumentState.UNSAVED` state and is considered a new Descartes Labs catalog object that must be created. If the catalog object already exists in this case, this method will raise a `~descarteslabs.exceptions.BadRequestError`. If this instance was retrieved using :py:meth:`get`, :py:meth:`get_or_create` or any other way (for example as part of a :py:meth:`search`), and any of its values were changed, it will be in the `~descarteslabs.catalog.DocumentState.MODIFIED` state and the existing catalog object will be updated. If this instance was retrieved using :py:meth:`get`, :py:meth:`get_or_create` or any other way (for example as part of a :py:meth:`search`), and none of its values were changed, it will be in the `~descarteslabs.catalog.DocumentState.SAVED` state, and if no `request_params` parameter is given, nothing will happen. Parameters ---------- request_params : dict, optional A dictionary of attributes that should be sent to the catalog along with attributes already set on this object. Empty by default. If not empty, and the object is in the `~descarteslabs.catalog.DocumentState.SAVED` state, it is updated in the Descartes Labs catalog even though no attributes were modified. headers : dict, optional A dictionary of header keys and values to be sent with the request. Raises ------ ConflictError If you're trying to create a new object and the object with given ``id`` already exists in the Descartes Labs catalog. BadRequestError If any of the attribute values are invalid. DeletedObjectError If this catalog object was deleted. ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError :ref:`Spurious exception <network_exceptions>` that can occur during a network request. """ if self.state == DocumentState.SAVED and not request_params: # Noop, already saved in the catalog return if self.state == DocumentState.UNSAVED: method = HttpRequestMethod.POST json = self.serialize(modified_only=False, jsonapi_format=True) else: method = HttpRequestMethod.PATCH json = self.serialize(modified_only=True, jsonapi_format=True) if request_params: json["data"]["attributes"].update(request_params) data, related_objects = self._send_data( method=method, id=self.id, json=json, client=self._client, headers=headers ) self._initialize( id=data["id"], saved=True, relationships=data.get("relationships"), related_objects=related_objects, **data["attributes"], ) @check_deleted def reload(self, request_params=None, headers=None): """Reload all attributes from the Descartes Labs catalog. Refresh the state of this catalog object from the object in the Descartes Labs catalog. This may be necessary if there are concurrent updates and the object in the Descartes Labs catalog was updated from another client. The instance state must be in the `~descarteslabs.catalog.DocumentState.SAVED` state. If you want to revert a modified object to its original one, you should use :py:meth:`get` on the object class with the object's `id`. Raises ------ ValueError If the catalog object is not in the ``SAVED`` state. DeletedObjectError If this catalog object was deleted. ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError :ref:`Spurious exception <network_exceptions>` that can occur during a network request. """ if self.state != DocumentState.SAVED: raise ValueError( "{} instance with id {} has not been saved".format( self.__class__.__name__, self.id ) ) data, related_objects = self._send_data( method=HttpRequestMethod.GET, id=self.id, client=self._client, request_params=request_params, headers=headers, ) # this will effectively wipe all current state & caching self._initialize( id=data["id"], saved=True, relationships=data.get("relationships"), related_objects=related_objects, **data["attributes"], ) @hybridmethod @check_derived def delete(cls, id, client=None): """Delete the catalog object with the given `id`. Parameters ---------- id : str The id of the object to be deleted. client : CatalogClient, optional A `CatalogClient` instance to use for requests to the Descartes Labs catalog. The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will be used if not set. Returns ------- bool ``True`` if this object was successfully deleted. ``False`` if the object was not found. Raises ------ ConflictError If the object has related objects (bands, images) that exist. ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError :ref:`Spurious exception <network_exceptions>` that can occur during a network request. Example ------- >>> Image.delete('my-image-id') # doctest: +SKIP There is also an instance ``delete`` method that can be used to delete an object. It accepts no parameters and does not return anything. Once deleted, you cannot use the catalog object and should release any references. """ if client is None: client = CatalogClient.get_default_client() try: client.session.delete(cls._url + "/" + id) return True # non-200 will raise an exception except NotFoundError: return False @delete.instancemethod @check_deleted def delete(self): """Delete this catalog object from the Descartes Labs catalog. Once deleted, you cannot use the catalog object and should release any references. Raises ------ DeletedObjectError If this catalog object was already deleted. UnsavedObjectError If this catalog object is being deleted without having been saved. ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError :ref:`Spurious exception <network_exceptions>` that can occur during a network request. """ if self.state == DocumentState.UNSAVED: raise UnsavedObjectError("You cannot delete an unsaved object.") self._client.session.delete(self._url + "/" + self.id) self._deleted = True # non-200 will raise an exception @classmethod @check_derived def _send_data( cls, method, id=None, json=None, client=None, request_params=None, headers=None ): client = client or CatalogClient.get_default_client() session_method = getattr(client.session, method.lower()) url = cls._url query_params = {} if method not in (HttpRequestMethod.POST, HttpRequestMethod.PUT): url += "/" + urllib.parse.quote(id) if request_params: query_params.update(**request_params) elif request_params: if json: json = dict(**json, **request_params) else: json = dict(**request_params) if cls._default_includes: query_params["include"] = ",".join(cls._default_includes) if query_params: url += "?" + urllib.parse.urlencode(query_params) r = session_method(url, json=json, headers=headers).json() data = r["data"] related_objects = cls._load_related_objects(r, client) return data, related_objects @classmethod def _load_related_objects(cls, response, client): related_objects = {} related_objects_serialized = response.get("included") if related_objects_serialized: for serialized in related_objects_serialized: model_class = cls._get_model_class(serialized) if model_class: related = model_class( id=serialized["id"], client=client, _saved=True, **serialized["attributes"], ) related_objects[(serialized["type"], serialized["id"])] = related return related_objects
[docs]class CatalogObject(CatalogObjectBase): """A base class for all representations of objects in the Descartes Labs catalog.""" extra_properties = ExtraPropertiesAttribute( doc="""dict, optional: A dictionary of up to 50 key/value pairs. The keys of this dictionary must be strings, and the values of this dictionary can be strings or numbers. This allows for more structured custom metadata to be associated with objects. """ ) tags = ListAttribute( TypedAttribute(str), doc="""list, optional: A list of up to 32 tags, each up to 1000 bytes long. The tags may support the classification and custom filtering of objects. *Filterable*. """, ) def __new__(cls, *args, **kwargs): return _new_abstract_class(cls, CatalogObject)
[docs]class AuthCatalogObject(CatalogObject): """A base class for all representations of objects in the Descartes Labs catalog that support ACLs. .. _auth_note: Note ---- The `readers` and `writers` IDs must be prefixed with ``email:``, ``user:``, ``group:`` or ``org:``. The `owners` IDs must be prefixed with ``org:`` or ``user:``. Using ``org:`` as an owner will assign those privileges only to administrators for that organization; using ``org:`` as a reader or writer assigns those privileges to everyone in that organization. The `readers` and `writers` attributes are only visible in full to an owner. If you are a reader or a writer those attributes will only display the elements of those lists by which you are gaining read or write access. Any user with owner privileges is able to read the object attributes and data, modify the object attributes, and delete the object, including reading and modifying the `owners`, `writers`, and `readers` attributes. Any user with writer privileges is able to read the object attributes and data, modify the object attributes except for `owners`, `writers`, and `readers`. A writer cannot delete the object. A writer can read the `owners` attribute but can only read the elements of `writers` and `readers` by which they gain access to the object. Any user with reader privileges is able to read the objects attributes and data. A reader can read the `owners` attribute but can only read the elements of `writers` and `readers` by which they gain access to the object. Also see :doc:`Sharing Resources </guides/sharing>`. """ owners = ListAttribute( TypedAttribute(str), doc="""list(str), optional: User, group, or organization IDs that own this object. Defaults to [``user:current_user``, ``org:current_org``]. The owner can edit, delete, and change access to this object. :ref:`See this note <auth_note>`. *Filterable*. """, ) readers = ListAttribute( TypedAttribute(str), doc="""list(str), optional: User, email, group, or organization IDs that can read this object. Will be empty by default. This attribute is only available in full to the `owners` of the object. :ref:`See this note <auth_note>`. """, ) writers = ListAttribute( TypedAttribute(str), doc="""list(str), optional: User, group, or organization IDs that can edit this object. Writers will also have read permission. Writers will be empty by default. See note below. This attribute is only available in full to the `owners` of the object. :ref:`See this note <auth_note>`. """, ) def __new__(cls, *args, **kwargs): return _new_abstract_class(cls, AuthCatalogObject)
[docs] def user_is_owner(self, auth=None): """Check if the authenticated user is an owner, and can perform actions such as changing ACLs or deleting this object. Parameters ---------- auth : Auth, optional The auth object to use for the check. If not provided, the default auth object will be used. Returns ------- bool True if the user is an owner of the object, False otherwise. """ if auth is None: auth = self._client.auth return "descarteslabs:platform-admin" in auth.payload.get("groups", []) or bool( set(self.owners) & auth.all_owner_acl_subjects_as_set )
[docs] def user_can_write(self, auth=None): """Check if the authenticated user is an owner or a writer and has permissions to modify this object. Parameters ---------- auth : Auth, optional The auth object to use for the check. If not provided, the default auth object will be used. Returns ------- bool True if the user can modify the object, False otherwise. """ if auth is None: auth = self._client.auth return self.user_is_owner(auth) or bool( set(self.writers) & auth.all_acl_subjects_as_set )
[docs] def user_can_read(self, auth=None): """Check if the authenticated user is an owner, a writer, or a reader and has permissions to read this object. Note it is kind of silly to call this method unless a non-default auth object is provided, because the default authorized user must have read permission in order to even retrieve this object. Parameters ---------- auth : Auth, optional The auth object to use for the check. If not provided, the default auth object will be used. Returns ------- bool True if the user can read the object, False otherwise. """ if auth is None: auth = self._client.auth return ( "descarteslabs:platform-ro" in auth.payload.get("groups", []) or self.user_can_write(auth) or bool(set(self.readers) & auth.all_acl_subjects_as_set) )