diff --git a/gravyladle_toolkit/__init__.py b/addon_interfaces/__init__.py similarity index 100% rename from gravyladle_toolkit/__init__.py rename to addon_interfaces/__init__.py diff --git a/addon_interfaces/_examples.py b/addon_interfaces/_examples.py new file mode 100644 index 00000000..ec47c8b9 --- /dev/null +++ b/addon_interfaces/_examples.py @@ -0,0 +1,34 @@ +from http import HTTPMethod + +from gravyvalet_interfaces import StorageInterface + + +# TODO: actual implementations +class _ExampleStorageImplementation(StorageInterface): + # implement method from StorageInterface + def item_download_url(self, item_id: str) -> str: + return self._waterbutler_download_url(item_id) + + # implement method from StorageInterface + async def get_item_description(self, item_id: str): + yield ("http://purl.org/dc/terms/identifier", item_id) + + # implement method from StorageInterface + def item_upload_url(self, item_id: str) -> str: + return self._waterbutler_upload_url(item_id) + + # implement method from StorageInterface + async def pls_delete_item(self, item_id: str): + await self.external_request( + HTTPMethod.DELETE, + self._external_url(item_id), + ) + + ### + # private, implementation-specific methods + + def _waterbutler_download_url(self, item_id): + raise NotImplementedError + + def _waterbutler_upload_url(self, item_id): + raise NotImplementedError diff --git a/addon_toolkit/__init__.py b/addon_toolkit/__init__.py new file mode 100644 index 00000000..2fac4af4 --- /dev/null +++ b/addon_toolkit/__init__.py @@ -0,0 +1,8 @@ +from .interface import BaseAddonInterface +from .storage import StorageInterface + + +__all__ = ( + "BaseAddonInterface", + "StorageInterface", +) diff --git a/addon_toolkit/interface.py b/addon_toolkit/interface.py new file mode 100644 index 00000000..083a49fc --- /dev/null +++ b/addon_toolkit/interface.py @@ -0,0 +1,175 @@ +import dataclasses +import inspect +import logging +from http import HTTPMethod + +import httpx # TODO: reconsider new dependency + +from addon_service.models import ( + AuthorizedStorageAccount, + ConfiguredStorageAddon, +) +from addon_toolkit.namespaces import GRAVY + + +__all__ = ( # public module attrs: + "BaseAddonInterface", + "immediate_capability", + "proxy_read_capability", + "proxy_act_capability", +) + + +_logger = logging.getLogger(__name__) + + +### +# decorators to declare capability identifiers on interface methods + + +def immediate_capability(capability_iri, *, requires): + # decorator for capabilities that can be computed immediately, + # without sending any requests or waiting on external resources + # (e.g. build a url in a known pattern or return declared static metadata) + def _decorator(fn): + # TODO: helpful error messaging for implementers + assert inspect.isfunction(fn) + assert not inspect.isawaitable(fn) + # TODO: assert based on `inspect.signature(fn).parameters` + _set_capability_iri(fn, capability_iri) + return fn # decorator stub (TODO: register someway addon_service can use it) + + return _decorator + + +def proxy_read_capability(capability_iri, *, requires): + # decorator for capabilities that require fetching data from elsewhere, + # but make no changes (e.g. get a metadata description of an item, + # list items in a given folder) + def _decorator(fn): + # TODO: assert based on `inspect.signature(fn)` + assert inspect.isasyncgenfunction(fn) + return fn + + return _decorator + + +def proxy_act_capability(capability_iri, *, requires): + # decorator for capabilities that initiate change, may take some time, + # and may fail in strange ways (e.g. delete an item, copy a file tree) + def _decorator(fn): + # TODO: assert based on `inspect.signature(fn)` + return fn + + return _decorator + + +### +# addon interface + + +@dataclasses.dataclass +class BaseAddonInterface: + ### + # public api for use on `self` when implementing capabilities + + # TODO: consider intermediate dataclasses to limit direct use of data models + authorized_account: AuthorizedStorageAccount + configured_addon: ConfiguredStorageAddon | None + + async def send_request(self, http_method: HTTPMethod, url: str, **kwargs): + """helper for external requests in addon implementations + + subclasses SHOULD use this instead of sending requests by hand + """ + # TODO: common http handling (retry, backoff, etc) to ease implementer load + _logger.info("sending %s to %s", http_method, url) + async with httpx.AsyncClient() as _client: # TODO: shared client? + _response = await _client.request( + http_method, + url, + **kwargs, + ) + return _response + + ### + # private api for capability book-keeping + + @classmethod + def __declared_capabilities(cls): + try: + return cls.__declared_capabilities + except AttributeError: + _declared_capabilities = cls.__declared_capabilities = dict( + cls.__iter_declared_capabilities() + ) + return _declared_capabilities + + @classmethod + def __iter_declared_capabilities(cls): + for _methodname, _fn in inspect.getmembers(cls, inspect.ismethod): + try: + _capability_iri = _get_capability_iri(_fn) + except AttributeError: + pass + else: # is capability + yield (_capability_iri, (_methodname, _fn)) + + raise NotImplementedError # TODO + + def __get_capability_method(self, capability_iri: str): + _declared_capabilities = self.__declared_capabilities() + try: + _methodname, _fn = _declared_capabilities[capability_iri] + except AttributeError: + return NotImplemented + # TODO: _method = getattr(... + + +### +# module-private helpers + + +def _get_capability_iri(fn): + # may raise AttributeError + return getattr(fn, GRAVY.capability) + + +def _set_capability_iri(capability_fn, capability_iri): + try: + _prior_value = _get_capability_iri(capability_fn) + except AttributeError: + _prior_value = None + if _prior_value is not None: + raise ValueError("cannot call _set_capability_iri twice (on %r)", capability_fn) + setattr(capability_fn, GRAVY.capability, capability_iri) + + +def _get_capability_method_map(obj): + try: + return getattr(obj, GRAVY.capability_map) + except AttributeError: + return _compute_capability_method_map(obj) + + +def _compute_capability_method_map(obj): + _capability_method_map = {} + for _methodname, _fn in inspect.getmembers(obj, inspect.ismethod): + # TODO: intent is to make it easy to implement the capabilities you are + # trying to support while ignoring all the rest (until you want them). + # on the base class, declare and decorate methods for each supported + # capability, then implementers may implement (or not implement) any or + # all of them -- this doesn't quite do all that, maybe try from __new__? + try: + _capability_iri = getattr(_fn, GRAVY.capability) + except AttributeError: + pass # not a capability implementation + else: + assert _capability_iri not in _capability_method_map, ( + f"duplicate implementations of capability <{_capability_iri}>" + f"(conflicting: {_fn}, {_capability_method_map[_capability_iri]})" + ) + _capability_method_map[_capability_iri] = _methodname + _logger.info("found capability methods on %r: %r", obj, _capability_method_map) + setattr(obj, GRAVY.capability_map, _capability_method_map) + return _capability_method_map diff --git a/addon_toolkit/namespaces.py b/addon_toolkit/namespaces.py new file mode 100644 index 00000000..dc941b83 --- /dev/null +++ b/addon_toolkit/namespaces.py @@ -0,0 +1,4 @@ +from primitive_metadata import primitive_rdf as rdf + + +GRAVY = rdf.IriNamespace("https://addons.osf.example/vocab/2023/") diff --git a/gravyladle_toolkit/storage.py b/addon_toolkit/storage.py similarity index 77% rename from gravyladle_toolkit/storage.py rename to addon_toolkit/storage.py index 1d0d8ae7..7eb0d522 100644 --- a/gravyladle_toolkit/storage.py +++ b/addon_toolkit/storage.py @@ -11,14 +11,14 @@ ) -# what a base StorageInterface could be like +# what a base StorageInterface could be like (incomplete) class StorageInterface(BaseAddonInterface): ## # "item-read" capabilities: @immediate_capability(GRAVY.item_download_url, requires={GRAVY.read}) def item_download_url(self, item_id: str) -> str: - raise NotImplementedError # waterbutler url, when appropriate + raise NotImplementedError # e.g. waterbutler url, when appropriate @proxy_read_capability(GRAVY.get_item_description, requires={GRAVY.read}) async def get_item_description(self, item_id: str) -> dict: @@ -86,25 +86,3 @@ async def get_version_ids(self, item_id: str) -> PagedResult[str]: ) async def pls_restore_version(self, item_id: str, version_id: str): raise NotImplementedError - - -if __debug__: # examples - - class _ExampleStorageImplementation(StorageInterface): - def item_download_url(self, item_id: str) -> str: - return self._waterbutler_download_url(item_id) - - async def get_item_description(self, item_id: str) -> dict: - return item_id # stub - - def item_upload_url(self, item_id: str) -> str: - return self._waterbutler_upload_url(item_id) - - async def pls_delete_item(self, item_id: str): - raise NotImplementedError - - def _waterbutler_download_url(self, item_id): - raise NotImplementedError - - def _waterbutler_upload_url(self, item_id): - raise NotImplementedError diff --git a/gravyladle_toolkit/capability.py b/gravyladle_toolkit/capability.py deleted file mode 100644 index ba7f3c6a..00000000 --- a/gravyladle_toolkit/capability.py +++ /dev/null @@ -1,102 +0,0 @@ -import inspect -import logging - -from gravyvalet.namespaces import GRAVY - - -_logger = logging.getLogger(__name__) - - -### -# ONE OPTION: use decorators to declare capability identifiers on interface methods - - -def immediate_capability(capability_iri, *, requires): - # decorator for capabilities that can be computed immediately, - # without sending any requests or waiting on external resources - # (e.g. build a url in a known pattern or return declared static metadata) - def _decorator(fn): - # assert not async? - _set_capability_iri(fn, capability_iri) - return fn # decorator stub (TODO: register someway addon_service can use it) - - return _decorator - - -def proxy_read_capability(capability_iri, *, requires): - # decorator for capabilities that require fetching data from elsewhere, - # but make no changes (e.g. get a metadata description of an item, - # list items in a given folder) - def _decorator(fn): - return fn # decorator stub (TODO) - - return _decorator - - -def proxy_act_capability(capability_iri, *, requires): - # decorator for capabilities that initiate change, may take some time, - # and may fail in strange ways (e.g. delete an item, copy a file tree) - def _decorator(fn): - return fn # decorator stub (TODO) - - return _decorator - - -### -# helpers for capability methods - - -def get_supported_capabilities(interface): - return set(_get_capability_method_map(interface).keys()) - - -def get_capability_method(interface_instance, capability_iri): - _methodname = _get_capability_method_map(interface_instance).get(capability_iri) - if _methodname is not None: - _method = getattr(interface_instance, _methodname) - # TODO: check whether it's abstract - - -### -# module-private helpers - - -def _get_capability_method_map(obj): - try: - return getattr(obj, GRAVY.capability_map) - except AttributeError: - return _compute_capability_method_map(obj) - - -def _compute_capability_method_map(obj): - _capability_method_map = {} - for _methodname, _fn in inspect.getmembers(obj, inspect.ismethod): - # TODO: intent is to make it easy to implement the capabilities you are - # trying to support while ignoring all the rest (until you want them). - # on the base class, declare and decorate methods for each supported - # capability, then implementers may implement (or not implement) any or - # all of them -- this doesn't quite do all that, maybe try from __new__? - try: - _capability_iri = getattr(_fn, GRAVY.capability) - except AttributeError: - pass # not a capability implementation - else: - assert _capability_iri not in _capability_method_map, ( - f"duplicate implementations of capability <{_capability_iri}>" - f"(conflicting: {_fn}, {_capability_method_map[_capability_iri]})" - ) - _capability_method_map[_capability_iri] = _methodname - _logger.info("found capability methods on %r: %r", obj, _capability_method_map) - setattr(obj, GRAVY.capability_map, _capability_method_map) - return _capability_method_map - - -def _get_capability_iri(fn): - return getattr(fn, GRAVY.capability, None) - - -def _set_capability_iri(capability_fn, capability_iri): - _prior_value = _get_capability_iri(capability_fn) - if _prior_value is not None: - raise ValueError - setattr(capability_fn, GRAVY.capability, capability_iri) diff --git a/gravyladle_toolkit/gatherstorage_class.py b/gravyladle_toolkit/gatherstorage_class.py deleted file mode 100644 index a2b72165..00000000 --- a/gravyladle_toolkit/gatherstorage_class.py +++ /dev/null @@ -1,102 +0,0 @@ -from primitive_metadata import gather -from primitive_metadata import primitive_rdf as rdf - -from addon_service.namespaces import GRAVY - -from .capability import ( - immediate_capability, - proxy_act_capability, - proxy_read_capability, -) -from .interfaces import PagedResult - - -# what an example gravy:StorageInterface implementation could be like (class-based) -STORAGE_INTERFACE_NORMS = gather.GatheringNorms( - namestory=( - rdf.Literal("Storage interface norms", language="en"), - rdf.Literal("Norms for an interface with a storage service", language="en"), - ), - focustype_iris={}, -) - - -class StorageInterfaceOrganizer(gather.GatheringOrganizer): - norms = STORAGE_INTERFACE_NORMS - thesaurus = {} - - -class StorageInterface(BaseAddonInterface): - ## - # "item-read" capabilities: - - @immediate_capability(GRAVY.item_download_url, requires={GRAVY.read}) - def item_download_url(self, item_id: str) -> str: - raise NotImplementedError # waterbutler url, when appropriate - - @proxy_read_capability(GRAVY.get_item_description, requires={GRAVY.read}) - async def get_item_description(self, item_id: str) -> dict: - raise NotImplementedError - - ## - # "item-write" capabilities: - - @immediate_capability(GRAVY.item_upload_url, requires={GRAVY.write}) - def item_upload_url(self, item_id: str) -> str: - raise NotImplementedError - - @proxy_act_capability(GRAVY.pls_delete_item, requires={GRAVY.write}) - async def pls_delete_item(self, item_id: str): - raise NotImplementedError - - ## - # "tree-read" capabilities: - - @proxy_read_capability(GRAVY.get_root_item_ids, requires={GRAVY.read, GRAVY.tree}) - async def get_root_item_ids(self) -> PagedResult[str]: - raise NotImplementedError - - @proxy_read_capability(GRAVY.get_parent_item_id, requires={GRAVY.read, GRAVY.tree}) - async def get_parent_item_id(self, item_id: str) -> str | None: - raise NotImplementedError - - @proxy_read_capability(GRAVY.get_item_path, requires={GRAVY.read, GRAVY.tree}) - async def get_item_path(self, item_id: str) -> str: - raise NotImplementedError - - @proxy_read_capability(GRAVY.get_child_item_ids, requires={GRAVY.read, GRAVY.tree}) - async def get_child_item_ids(self, item_id: str) -> PagedResult[str]: - raise NotImplementedError - - ## - # "tree-write" capabilities - - @proxy_act_capability(GRAVY.pls_move_item, requires={GRAVY.write, GRAVY.tree}) - async def pls_move_item(self, item_id: str, new_treepath: str): - raise NotImplementedError - - @proxy_act_capability(GRAVY.pls_copy_item, requires={GRAVY.write, GRAVY.tree}) - async def pls_copy_item(self, item_id: str, new_treepath: str): - raise NotImplementedError - - ## - # "version-read" capabilities - - @proxy_read_capability( - GRAVY.get_current_version_id, requires={GRAVY.read, GRAVY.version} - ) - async def get_current_version_id(self, item_id: str) -> str: - raise NotImplementedError - - @proxy_read_capability(GRAVY.get_version_ids, requires={GRAVY.read, GRAVY.version}) - async def get_version_ids(self, item_id: str) -> PagedResult[str]: - raise NotImplementedError - - ## - # "version-write" capabilities - - @proxy_act_capability( - GRAVY.pls_restore_version, requires={GRAVY.write, GRAVY.version} - ) - async def pls_restore_version(self, item_id: str, version_id: str): - raise NotImplementedError diff --git a/gravyladle_toolkit/interfaces.py b/gravyladle_toolkit/interfaces.py deleted file mode 100644 index c2a4acc7..00000000 --- a/gravyladle_toolkit/interfaces.py +++ /dev/null @@ -1,32 +0,0 @@ -import dataclasses -from typing import ( - Awaitable, - Callable, -) - -from addon_service.models import ( - AuthorizedStorageAccount, - ConfiguredStorageAddon, -) - - -@dataclasses.dataclass -class PagedResult: # (TODO: move to gravyvalet) - page: list - get_next_page: Callable[[], Awaitable["PagedResult"]] # (TODO: cursor?) - - -@dataclasses.dataclass -class BaseAddonInterface: - # attrs on `self` when your StorageInterface subclass is instantiated - authorized_account: AuthorizedStorageAccount - configured_addon: ConfiguredStorageAddon | None - - def invoke_immediate_capability(self, capability_iri: str, **kwargs): - raise NotImplementedError # TODO - - def invoke_proxy_read_capability(self, capability_iri: str, **kwargs): - raise NotImplementedError # TODO - - def invoke_proxy_act_capability(self, capability_iri: str, **kwargs): - raise NotImplementedError # TODO