diff --git a/addon_service/authorized_storage_account/models.py b/addon_service/authorized_storage_account/models.py index f4b4b6a1..40c282eb 100644 --- a/addon_service/authorized_storage_account/models.py +++ b/addon_service/authorized_storage_account/models.py @@ -1,11 +1,15 @@ -# from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.fields import ArrayField from django.db import models +from addon_service.capability.models import IntStorageCapability from addon_service.common.base_model import AddonsServiceBaseModel class AuthorizedStorageAccount(AddonsServiceBaseModel): - # TODO: authorized_capabilities = ArrayField(...) + authorized_capabilities = ArrayField( + models.IntegerField(choices=IntStorageCapability.as_django_choices()), + ) + default_root_folder = models.CharField(blank=True) external_storage_service = models.ForeignKey( diff --git a/addon_service/authorized_storage_account/serializers.py b/addon_service/authorized_storage_account/serializers.py index 49d2a905..85076b70 100644 --- a/addon_service/authorized_storage_account/serializers.py +++ b/addon_service/authorized_storage_account/serializers.py @@ -5,6 +5,7 @@ ) from rest_framework_json_api.utils import get_resource_type_from_model +from addon_service.capability.serializers import StorageCapabilityListField from addon_service.models import ( AuthorizedStorageAccount, ConfiguredStorageAddon, @@ -43,6 +44,7 @@ def __init__(self, *args, **kwargs): url = serializers.HyperlinkedIdentityField( view_name=f"{RESOURCE_NAME}-detail", required=False ) + authorized_capabilities = StorageCapabilityListField() account_owner = AccountOwnerField( many=False, queryset=UserReference.objects.all(), @@ -74,6 +76,7 @@ def __init__(self, *args, **kwargs): def create(self, validate_data): account_owner = validate_data["account_owner"] + authorized_capabilities = validate_data["authorized_capabilities"] external_storage_service = validate_data["external_storage_service"] # TODO(ENG-5189): Update this once credentials format is finalized credentials, created = ExternalCredentials.objects.get_or_create( @@ -90,6 +93,7 @@ def create(self, validate_data): return AuthorizedStorageAccount.objects.create( external_storage_service=external_storage_service, external_account=external_account, + authorized_capabilities=authorized_capabilities, ) class Meta: @@ -102,4 +106,5 @@ class Meta: "external_storage_service", "username", "password", + "authorized_capabilities", ] diff --git a/addon_service/capability/__init__.py b/addon_service/capability/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/addon_service/capability/models.py b/addon_service/capability/models.py new file mode 100644 index 00000000..799aa80d --- /dev/null +++ b/addon_service/capability/models.py @@ -0,0 +1,10 @@ +from addon_service.common.enums import IntEnumForEnum +from addon_toolkit.storage import StorageCapability + + +__all__ = ("IntStorageCapability",) + + +class IntStorageCapability(IntEnumForEnum, base_enum=StorageCapability): + ACCESS = 1 + UPDATE = 2 diff --git a/addon_service/capability/serializers.py b/addon_service/capability/serializers.py new file mode 100644 index 00000000..5995c79e --- /dev/null +++ b/addon_service/capability/serializers.py @@ -0,0 +1,11 @@ +from addon_service.capability.models import IntStorageCapability +from addon_service.common.enums.serializers import DualEnumsListField +from addon_toolkit.storage import StorageCapability + + +class StorageCapabilityListField( + DualEnumsListField, + external_enum=StorageCapability, + internal_enum=IntStorageCapability, +): + pass diff --git a/addon_service/common/base_model.py b/addon_service/common/base_model.py index 52901ad5..f7984eef 100644 --- a/addon_service/common/base_model.py +++ b/addon_service/common/base_model.py @@ -10,6 +10,7 @@ def save(self, *args, **kwargs): if not self.id: self.created = timezone.now() self.modified = timezone.now() + self.full_clean() super().save(*args, **kwargs) def __str__(self): diff --git a/addon_service/common/enums/__init__.py b/addon_service/common/enums/__init__.py new file mode 100644 index 00000000..bbcd878a --- /dev/null +++ b/addon_service/common/enums/__init__.py @@ -0,0 +1,29 @@ +import enum +from typing import ClassVar + + +def same_enum_names(enum_a: type[enum.Enum], enum_b: type[enum.Enum]) -> bool: + # ensure enums have same names + _names_a = {_item.name for _item in enum_a} + _names_b = {_item.name for _item in enum_b} + return _names_a == _names_b + + +class IntEnumForEnum(enum.IntEnum): + __base_enum: ClassVar[type[enum.Enum]] + + def __init_subclass__(cls, /, base_enum: type[enum.Enum], **kwargs): + super().__init_subclass__(**kwargs) + assert same_enum_names(base_enum, cls) + cls.__base_enum = base_enum + + @classmethod + def to_int(cls, base_enum_member): + return cls[base_enum_member.name] + + @classmethod + def as_django_choices(cls): + return [(int(_item), _item.name) for _item in cls] + + def to_base_enum(self) -> enum.Enum: + return self.__base_enum[self.name] diff --git a/addon_service/common/enums/serializers.py b/addon_service/common/enums/serializers.py new file mode 100644 index 00000000..455f998b --- /dev/null +++ b/addon_service/common/enums/serializers.py @@ -0,0 +1,50 @@ +import enum +from typing import ClassVar + +from rest_framework_json_api import serializers + +from addon_service.common.enums import same_enum_names + + +class DualEnumsListField(serializers.MultipleChoiceField): + """use one enum in your database and another in your api!""" + + __internal_enum: ClassVar[type[enum.Enum]] + __external_enum: ClassVar[type[enum.Enum]] + + def __init__(self, **kwargs): + super().__init__( + **kwargs, + choices={ # valid serialized values come from the external enum + _external_member.value for _external_member in self.__external_enum + }, + ) + + def __init_subclass__( + cls, + /, + internal_enum: type[enum.Enum], + external_enum: type[enum.Enum], + **kwargs, + ): + super().__init_subclass__(**kwargs) + assert same_enum_names(internal_enum, external_enum) + cls.__internal_enum = internal_enum + cls.__external_enum = external_enum + + def to_internal_value(self, data) -> list[enum.Enum]: + _names: set = super().to_internal_value(data) + return [self._to_internal_enum_member(_name) for _name in _names] + + def to_representation(self, value): + _member_list = super().to_representation(value) + return {self._to_external_enum_value(_member) for _member in _member_list} + + def _to_internal_enum_member(self, external_value) -> enum.Enum: + _external_member = self.__external_enum(external_value) + return self.__internal_enum[_external_member.name] + + def _to_external_enum_value(self, internal_value: enum.Enum): + _internal_member = self.__internal_enum(internal_value) + _external_member = self.__external_enum[_internal_member.name] + return _external_member.value diff --git a/addon_service/configured_storage_addon/models.py b/addon_service/configured_storage_addon/models.py index cafc8976..65949bbf 100644 --- a/addon_service/configured_storage_addon/models.py +++ b/addon_service/configured_storage_addon/models.py @@ -1,10 +1,17 @@ +from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError from django.db import models +from addon_service.capability.models import IntStorageCapability from addon_service.common.base_model import AddonsServiceBaseModel class ConfiguredStorageAddon(AddonsServiceBaseModel): - root_folder = models.CharField() + root_folder = models.CharField(blank=True) + + connected_capabilities = ArrayField( + models.IntegerField(choices=IntStorageCapability.as_django_choices()), + ) base_account = models.ForeignKey( "addon_service.AuthorizedStorageAccount", @@ -28,3 +35,15 @@ class JSONAPIMeta: @property def account_owner(self): return self.base_account.external_account.owner + + def clean(self): + _connected_caps = set(self.connected_capabilities) + if not _connected_caps.issubset(self.base_account.authorized_capabilities): + _unauthorized_caps = _connected_caps.difference( + self.base_account.authorized_capabilities + ) + raise ValidationError( + { + "connected_capabilities": f"capabilities not authorized on account: {_unauthorized_caps}", + } + ) diff --git a/addon_service/configured_storage_addon/serializers.py b/addon_service/configured_storage_addon/serializers.py index 68d5744d..486bcda1 100644 --- a/addon_service/configured_storage_addon/serializers.py +++ b/addon_service/configured_storage_addon/serializers.py @@ -2,6 +2,7 @@ from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.utils import get_resource_type_from_model +from addon_service.capability.serializers import StorageCapabilityListField from addon_service.models import ( AuthorizedStorageAccount, ConfiguredStorageAddon, @@ -23,6 +24,7 @@ def to_internal_value(self, data): class ConfiguredStorageAddonSerializer(serializers.HyperlinkedModelSerializer): root_folder = serializers.CharField(required=False) url = serializers.HyperlinkedIdentityField(view_name=f"{RESOURCE_NAME}-detail") + connected_capabilities = StorageCapabilityListField() base_account = ResourceRelatedField( queryset=AuthorizedStorageAccount.objects.all(), many=False, @@ -48,4 +50,5 @@ class Meta: "root_folder", "base_account", "authorized_resource", + "connected_capabilities", ] diff --git a/addon_service/external_account/models.py b/addon_service/external_account/models.py index 83a70910..d1ba1b45 100644 --- a/addon_service/external_account/models.py +++ b/addon_service/external_account/models.py @@ -4,10 +4,6 @@ class ExternalAccount(AddonsServiceBaseModel): - # The user's ID on the remote service - remote_account_id = models.CharField() - remote_account_display_name = models.CharField() - credentials_issuer = models.ForeignKey( "addon_service.CredentialsIssuer", on_delete=models.CASCADE, diff --git a/addon_service/management/commands/fill_garbage.py b/addon_service/management/commands/fill_garbage.py index 32caa0db..90e8935f 100644 --- a/addon_service/management/commands/fill_garbage.py +++ b/addon_service/management/commands/fill_garbage.py @@ -26,8 +26,6 @@ def handle_label(self, label, **options): ) _ec = db.ExternalCredentials.objects.create() _ea = db.ExternalAccount.objects.create( - remote_account_id=label, - remote_account_display_name=label, credentials_issuer=_ci, owner=_iu, credentials=_ec, diff --git a/addon_service/migrations/0001_initial.py b/addon_service/migrations/0001_initial.py index fdef5d67..41485906 100644 --- a/addon_service/migrations/0001_initial.py +++ b/addon_service/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 4.2.7 on 2023-12-11 20:02 +# Generated by Django 4.2.7 on 2024-02-07 22:04 +import django.contrib.postgres.fields import django.db.models.deletion from django.db import ( migrations, @@ -27,6 +28,15 @@ class Migration(migrations.Migration): ), ("created", models.DateTimeField(editable=False)), ("modified", models.DateTimeField()), + ( + "authorized_capabilities", + django.contrib.postgres.fields.ArrayField( + base_field=models.IntegerField( + choices=[(1, "ACCESS"), (2, "UPDATE")] + ), + size=None, + ), + ), ("default_root_folder", models.CharField(blank=True)), ], options={ @@ -167,8 +177,6 @@ class Migration(migrations.Migration): ), ("created", models.DateTimeField(editable=False)), ("modified", models.DateTimeField()), - ("remote_account_id", models.CharField()), - ("remote_account_display_name", models.CharField()), ( "credentials", models.ForeignKey( @@ -215,19 +223,28 @@ class Migration(migrations.Migration): ("modified", models.DateTimeField()), ("root_folder", models.CharField()), ( - "base_account", + "connected_capabilities", + django.contrib.postgres.fields.ArrayField( + base_field=models.IntegerField( + choices=[(1, "ACCESS"), (2, "UPDATE")] + ), + size=None, + ), + ), + ( + "authorized_resource", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="configured_storage_addons", - to="addon_service.authorizedstorageaccount", + to="addon_service.resourcereference", ), ), ( - "authorized_resource", + "base_account", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="configured_storage_addons", - to="addon_service.resourcereference", + to="addon_service.authorizedstorageaccount", ), ), ], diff --git a/addon_service/tests/_factories.py b/addon_service/tests/_factories.py index 63b5a5db..2e429059 100644 --- a/addon_service/tests/_factories.py +++ b/addon_service/tests/_factories.py @@ -2,6 +2,7 @@ from factory.django import DjangoModelFactory from addon_service import models as db +from addon_service.capability.models import IntStorageCapability class UserReferenceFactory(DjangoModelFactory): @@ -22,6 +23,8 @@ class CredentialsIssuerFactory(DjangoModelFactory): class Meta: model = db.CredentialsIssuer + name = factory.Faker("word") + class ExternalCredentialsFactory(DjangoModelFactory): class Meta: @@ -32,9 +35,6 @@ class ExternalAccountFactory(DjangoModelFactory): class Meta: model = db.ExternalAccount - remote_account_id = factory.Faker("word") - remote_account_display_name = factory.Faker("word") - credentials_issuer = factory.SubFactory(CredentialsIssuerFactory) owner = factory.SubFactory(UserReferenceFactory) credentials = factory.SubFactory(ExternalCredentialsFactory) @@ -59,6 +59,7 @@ class Meta: model = db.AuthorizedStorageAccount default_root_folder = "/" + authorized_capabilities = factory.List([IntStorageCapability.ACCESS]) external_storage_service = factory.SubFactory(ExternalStorageServiceFactory) external_account = factory.SubFactory(ExternalAccountFactory) # TODO: external_account.credentials_issuer same as @@ -70,5 +71,6 @@ class Meta: model = db.ConfiguredStorageAddon root_folder = "/" + connected_capabilities = factory.List([IntStorageCapability.ACCESS]) base_account = factory.SubFactory(AuthorizedStorageAccountFactory) authorized_resource = factory.SubFactory(ResourceReferenceFactory) diff --git a/addon_service/tests/test_by_type/test_authorized_storage_account.py b/addon_service/tests/test_by_type/test_authorized_storage_account.py index dc61d99e..f8518e32 100644 --- a/addon_service/tests/test_by_type/test_authorized_storage_account.py +++ b/addon_service/tests/test_by_type/test_authorized_storage_account.py @@ -110,8 +110,13 @@ def test_get(self): set(_content["data"]["attributes"].keys()), { "default_root_folder", + "authorized_capabilities", }, ) + self.assertEqual( + _content["data"]["attributes"]["authorized_capabilities"], + ["access"], + ) self.assertEqual( set(_content["data"]["relationships"].keys()), { @@ -187,6 +192,7 @@ def test_post(self): "attributes": { "username": "", "password": "", + "authorized_capabilities": ["access", "update"], }, "relationships": { "external_storage_service": { diff --git a/addon_service/tests/test_by_type/test_configured_storage_addon.py b/addon_service/tests/test_by_type/test_configured_storage_addon.py index 4fd3cd9e..8acadd0a 100644 --- a/addon_service/tests/test_by_type/test_configured_storage_addon.py +++ b/addon_service/tests/test_by_type/test_configured_storage_addon.py @@ -97,8 +97,13 @@ def test_get(self): set(_content["data"]["attributes"].keys()), { "root_folder", + "connected_capabilities", }, ) + self.assertEqual( + _content["data"]["attributes"]["connected_capabilities"], + ["access"], + ) self.assertEqual( set(_content["data"]["relationships"].keys()), { @@ -155,6 +160,9 @@ def setUpTestData(cls): cls.default_payload = { "data": { "type": "configured-storage-addons", + "attributes": { + "connected_capabilities": ["access"], + }, "relationships": { "base_account": { "data": { diff --git a/addon_toolkit/__init__.py b/addon_toolkit/__init__.py new file mode 100644 index 00000000..ccd995ba --- /dev/null +++ b/addon_toolkit/__init__.py @@ -0,0 +1,22 @@ +from .interface import ( + PagedResult, + addon_interface, + get_operation_declarations, + get_operation_implementations, + is_operation_implemented_on, +) +from .operation import ( + proxy_operation, + redirect_operation, +) + + +__all__ = ( + "PagedResult", + "addon_interface", + "proxy_operation", + "redirect_operation", + "get_operation_declarations", + "get_operation_implementations", + "is_operation_implemented_on", +) diff --git a/addon_toolkit/declarator.py b/addon_toolkit/declarator.py new file mode 100644 index 00000000..67876d5a --- /dev/null +++ b/addon_toolkit/declarator.py @@ -0,0 +1,105 @@ +import dataclasses +import weakref + + +@dataclasses.dataclass +class Declarator: + """Declarator: add declarative metadata in python using decorators and dataclasses + + define a dataclass with fields you want declared in your decorator, plus a field + to hold the object of declaration: + >>> @dataclasses.dataclass + ... class TwoPartGreetingDeclaration: + ... a: str + ... b: str + ... on: object + + use that dataclass to define a declarator: + >>> greet = Declarator(TwoPartGreetingDeclaration, object_field='on') + + use the declarator as a decorator to create a declaration: + >>> @greet(a='hey', b='hello') + ... def _hihi(): + ... pass + + use the declarator to access declarations by object: + >>> greet.get_declaration(_hihi) + TwoPartGreetingDeclaration(a='hey', b='hello', on=) + + use `.with_kwargs` to create aliased decorators with static values: + >>> ora = greet.with_kwargs(b='ora') + >>> @ora(a='kia') + ... def _kia_ora(): + ... pass + + and find that aliased decoration via the original declarator: + >>> greet.get_declaration(_kia_ora) + TwoPartGreetingDeclaration(a='kia', b='ora', on=) + """ + + declaration_dataclass: type + object_field: str + static_kwargs: dict | None = None + + # private storage linking a decorated class or function to data gleaned from its decorator + __declarations_by_target: weakref.WeakKeyDictionary = dataclasses.field( + default_factory=weakref.WeakKeyDictionary, + ) + + def __post_init__(self): + assert dataclasses.is_dataclass( + self.declaration_dataclass + ), f"expected dataclass, got {self.declaration_dataclass}" + assert any( + _field.name == self.object_field + for _field in dataclasses.fields(self.declaration_dataclass) + ), f'expected field "{self.object_field}" on dataclass "{self.declaration_dataclass}"' + + def __call__(self, **decorator_kwargs): + def _decorator(decorator_target): + self.declare(decorator_target, decorator_kwargs) + return decorator_target + + return _decorator + + def with_kwargs(self, **static_kwargs): + """convenience for decorators that differ only by static field values""" + # note: shared __declarations_by_target + return dataclasses.replace(self, static_kwargs=static_kwargs) + + def declare(self, decorator_target, decorator_kwargs: dict): + # dataclass validates decorator kwarg names + self.__declarations_by_target[decorator_target] = self.declaration_dataclass( + **decorator_kwargs, + **(self.static_kwargs or {}), + **{self.object_field: decorator_target}, + ) + + def get_declaration(self, target): + try: + return self.__declarations_by_target[target] + except KeyError: + raise ValueError(f"no declaration found for {target}") + + +class ClassDeclarator(Declarator): + """add declarative metadata to python classes using decorators + + (same as Declarator but with additional methods that only make + sense when used to decorate classes, and allow for inheritance + and class instances) + """ + + def get_declaration_for_class_or_instance(self, type_or_object: type | object): + _cls = ( + type_or_object if isinstance(type_or_object, type) else type(type_or_object) + ) + return self.get_declaration_for_class(_cls) + + def get_declaration_for_class(self, cls: type): + for _cls in cls.__mro__: + try: + return self.get_declaration(_cls) + except ValueError: # TODO: more helpful exception + pass + raise ValueError(f"no declaration found for {cls}") diff --git a/addon_toolkit/interface.py b/addon_toolkit/interface.py new file mode 100644 index 00000000..f003f0d0 --- /dev/null +++ b/addon_toolkit/interface.py @@ -0,0 +1,162 @@ +import dataclasses +import enum +import inspect +import logging +from typing import ( + Callable, + Iterator, +) + +from .declarator import ClassDeclarator +from .operation import ( + AddonOperationDeclaration, + operation_declarator, +) + + +__all__ = ( + "PagedResult", + "addon_interface", + "get_operation_declarations", + "get_operation_implementations", + "is_operation_implemented_on", +) + +_logger = logging.getLogger(__name__) + + +@dataclasses.dataclass +class PagedResult: # TODO: consistent handling of paged results + page: list + next_page_cursor: str + + +@dataclasses.dataclass +class AddonInterfaceDeclaration: + """dataclass for the operations declared on a class decorated with `addon_interface`""" + + interface_cls: type + capability_enum: type[enum.Enum] + method_name_by_op: dict[AddonOperationDeclaration, str] = dataclasses.field( + default_factory=dict, + ) + ops_by_capability: dict[ + enum.Enum, set[AddonOperationDeclaration] + ] = dataclasses.field( + default_factory=dict, + ) + + ### + # private methods for populating operations + + def __post_init__(self): + self._gather_operations() + + def _gather_operations(self): + for _name, _fn in inspect.getmembers(self.interface_cls, inspect.isfunction): + _maybe_op = operation_declarator.get_declaration(_fn) + if _maybe_op is not None: + self._add_operation(_name, _maybe_op) + + def _add_operation(self, method_name: str, operation: AddonOperationDeclaration): + assert operation not in self.method_name_by_op, ( + f"duplicate operation '{operation}'" f" on {self.interface_cls}" + ) + self.method_name_by_op[operation] = method_name + self.ops_by_capability.setdefault( + operation.capability, + set(), + ).add(operation) + + +# the class decorator itself +addon_interface = ClassDeclarator( + declaration_dataclass=AddonInterfaceDeclaration, + object_field="interface_cls", +) + + +@dataclasses.dataclass(frozen=True) +class AddonOperationImplementation: + """dataclass for an implemented operation on an interface subclass""" + + implementation_cls: type + operation: AddonOperationDeclaration + + def __post_init__(self): + if self.implementation_fn is self.interface_fn: # may raise NotImplementedError + raise NotImplementedError( # TODO: helpful exception type + f"operation '{self.operation}' not implemented by {self.implementation_cls}" + ) + + @property + def interface(self) -> AddonInterfaceDeclaration: + return addon_interface.get_declaration_for_class(self.implementation_cls) + + @property + def interface_fn(self) -> Callable: + return getattr(self.interface.interface_cls, self.method_name) + + @property + def method_name(self) -> str: + return self.interface.method_name_by_op[self.operation] + + @property + def implementation_fn(self) -> Callable: + return getattr(self.implementation_cls, self.method_name) + + @property + def combined_docstring(self) -> str | None: + return "\n".join( + ( + self.operation.docstring or "", + self.implementation_fn.__doc__ or "", + ) + ) + + def get_callable_for(self, addon_instance: object) -> Callable: + return getattr(addon_instance, self.method_name) + + +def get_operation_declarations( + interface: type | object, capability: enum.Enum | None = None +) -> Iterator[AddonOperationDeclaration]: + _interface_dec = addon_interface.get_declaration_for_class_or_instance(interface) + if capability is None: + yield from _interface_dec.method_name_by_op.keys() + else: + yield from _interface_dec.ops_by_capability.get(capability, ()) + + +def get_operation_implementations( + implementation_cls: type, + capability: enum.Enum | None = None, +) -> Iterator[AddonOperationImplementation]: + for _op in get_operation_declarations(implementation_cls, capability=capability): + try: + yield AddonOperationImplementation(implementation_cls, _op) + except NotImplementedError: + pass + + +def is_operation_implemented_on( + operation: AddonOperationDeclaration, + implementation_cls: type, +) -> bool: + try: + return bool(AddonOperationImplementation(implementation_cls, operation)) + except NotImplementedError: # TODO: more specific error + return False + + +def invoke( + operation: AddonOperationDeclaration, + interface_instance: object, + /, + args=None, + kwargs=None, +): + # TODO: reconsider + _imp = AddonOperationImplementation(interface_instance.__class__, operation) + _method = _imp.get_callable_for(interface_instance) + return _method(*(args or ()), **(kwargs or {})) diff --git a/addon_toolkit/network/__init__.py b/addon_toolkit/network/__init__.py new file mode 100644 index 00000000..18efda13 --- /dev/null +++ b/addon_toolkit/network/__init__.py @@ -0,0 +1,25 @@ +"""TODO: give addon implementers an easy way to declare the network requests +their addon needs while allowing consistent handling in any given addon_service +implementation +""" +import http +import logging + + +_logger = logging.getLogger(__name__) + + +async def send_request(self, http_method: http.HTTPMethod, url: str, **kwargs): + """helper for external requests in addon implementations + + subclasses SHOULD use this instead of sending requests by hand + """ + _logger.info("sending %s to %s", http_method, url) + # TODO: common http handling (retry, backoff, etc) to ease implementer load + # async with httpx.AsyncClient() as _client: # TODO: shared client? + # _response = await _client.request( + # http_method, + # url, + # **kwargs, + # ) + # return _response diff --git a/addon_toolkit/operation.py b/addon_toolkit/operation.py new file mode 100644 index 00000000..617d8792 --- /dev/null +++ b/addon_toolkit/operation.py @@ -0,0 +1,62 @@ +import dataclasses +import enum +from typing import Callable + +from .declarator import Declarator + + +__all__ = ( + "AddonOperationDeclaration", + "AddonOperationType", + "operation_declarator", + "proxy_operation", + "redirect_operation", +) + + +class AddonOperationType(enum.Enum): + REDIRECT = "redirect" + PROXY = "proxy" + + +@dataclasses.dataclass(frozen=True) +class AddonOperationDeclaration: + """dataclass for a declared operation method on an interface + + created by decorating a method with `@proxy_operation` or `@redirect_operation` + on a class decorated with `@addon_operation`. + """ + + operation_type: AddonOperationType + capability: enum.Enum + operation_fn: Callable + + ### + # instance methods + + @property + def docstring(self) -> str | None: + # TODO: consider docstring param on operation decorators, allow overriding __doc__ + return self.operation_fn.__doc__ + + @classmethod + def for_function(self, fn: Callable) -> "AddonOperationDeclaration": + return operation_declarator.get_declaration(fn) + + +# decorator for operations (used by operation_type-specific decorators below) +operation_declarator = Declarator( + declaration_dataclass=AddonOperationDeclaration, + object_field="operation_fn", +) + +# decorator for operations that may be performed by a client request (e.g. redirect to waterbutler) +redirect_operation = operation_declarator.with_kwargs( + operation_type=AddonOperationType.REDIRECT, +) + +# decorator for operations 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) +proxy_operation = operation_declarator.with_kwargs( + operation_type=AddonOperationType.PROXY, +) diff --git a/addon_toolkit/storage.py b/addon_toolkit/storage.py new file mode 100644 index 00000000..2c3b1c10 --- /dev/null +++ b/addon_toolkit/storage.py @@ -0,0 +1,90 @@ +import enum + +from addon_toolkit import ( + PagedResult, + addon_interface, + proxy_operation, + redirect_operation, +) + + +__all__ = ("StorageAddon",) + + +class StorageCapability(enum.Enum): + ACCESS = "access" + UPDATE = "update" + + +# what a base StorageAddon could be like (incomplete) +@addon_interface(capability_enum=StorageCapability) +class StorageAddon: + ## + # "item-read" operations: + + @redirect_operation(capability=StorageCapability.ACCESS) + def item_download_url(self, item_id: str) -> str: + raise NotImplementedError # e.g. waterbutler url, when appropriate + + @proxy_operation(capability=StorageCapability.ACCESS) + async def get_item_description(self, item_id: str) -> dict: + raise NotImplementedError + + ## + # "item-write" operations: + + @redirect_operation(capability=StorageCapability.UPDATE) + def item_upload_url(self, item_id: str) -> str: + raise NotImplementedError + + @proxy_operation(capability=StorageCapability.UPDATE) + async def pls_delete_item(self, item_id: str): + raise NotImplementedError + + ## + # "tree-read" operations: + + @proxy_operation(capability=StorageCapability.ACCESS) + async def get_root_item_ids(self) -> PagedResult: + raise NotImplementedError + + @proxy_operation(capability=StorageCapability.ACCESS) + async def get_parent_item_id(self, item_id: str) -> str | None: + raise NotImplementedError + + @proxy_operation(capability=StorageCapability.ACCESS) + async def get_item_path(self, item_id: str) -> str: + raise NotImplementedError + + @proxy_operation(capability=StorageCapability.ACCESS) + async def get_child_item_ids(self, item_id: str) -> PagedResult: + raise NotImplementedError + + ## + # "tree-write" operations + + @proxy_operation(capability=StorageCapability.UPDATE) + async def pls_move_item(self, item_id: str, new_treepath: str): + raise NotImplementedError + + @proxy_operation(capability=StorageCapability.UPDATE) + async def pls_copy_item(self, item_id: str, new_treepath: str): + raise NotImplementedError + + ## + # "version-read" operations + + @proxy_operation(capability=StorageCapability.ACCESS) + async def get_current_version_id(self, item_id: str) -> str: + raise NotImplementedError + + @proxy_operation(capability=StorageCapability.ACCESS) + async def get_version_ids(self, item_id: str) -> PagedResult: + raise NotImplementedError + + ## + # "version-write" operations + + @proxy_operation(capability=StorageCapability.UPDATE) + async def pls_restore_version(self, item_id: str, version_id: str): + raise NotImplementedError diff --git a/addon_toolkit/tests/__init__.py b/addon_toolkit/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/addon_toolkit/tests/_doctest.py b/addon_toolkit/tests/_doctest.py new file mode 100644 index 00000000..0c833a36 --- /dev/null +++ b/addon_toolkit/tests/_doctest.py @@ -0,0 +1,31 @@ +import doctest + + +def load_doctests(*modules): + """shorthand for unittests from doctests + + meant for implementing the "load_tests protocol" + https://docs.python.org/3/library/unittest.html#load-tests-protocol + + suggested use, in a separate file from `unittest` unit tests + ``` + from addon_toolkit.tests._doctest import load_doctests + import my.module.with.doctests + + load_tests = load_doctests(my.module.with.doctests) + ``` + + (if there's a need, could support pass-thru kwargs to DocTestSuite) + """ + + def _load_tests(loader, tests, pattern): + for _module in modules: + tests.addTests( + doctest.DocTestSuite( + _module, + optionflags=doctest.ELLIPSIS, + ) + ) + return tests + + return _load_tests diff --git a/addon_toolkit/tests/test_addon_interface.py b/addon_toolkit/tests/test_addon_interface.py new file mode 100644 index 00000000..59ab0c51 --- /dev/null +++ b/addon_toolkit/tests/test_addon_interface.py @@ -0,0 +1,155 @@ +import enum +import unittest + +from addon_toolkit import ( + addon_interface, + get_operation_declarations, + get_operation_implementations, + proxy_operation, + redirect_operation, +) +from addon_toolkit.interface import AddonOperationImplementation +from addon_toolkit.operation import ( + AddonOperationDeclaration, + AddonOperationType, +) + + +class TestAddonInterface(unittest.TestCase): + @classmethod + def setUpClass(cls): + ### + # declare the capabilities and interface for a category of addons + + class _MyCapability(enum.Enum): + GET_IT = "get-it" + PUT_IT = "put-it" + UNUSED = "unused" # for testing when a capability has no operations + + @addon_interface(capability_enum=_MyCapability) + class _MyInterface: + """this _MyInterface docstring should find its way to browsable docs somewhere""" + + @redirect_operation(capability=_MyCapability.GET_IT) + def url_for_get(self, checksum_iri) -> str: + """this url_for_get docstring should find its way to docs""" + raise NotImplementedError + + @proxy_operation(capability=_MyCapability.GET_IT) + async def query_relations(self, checksum_iri, query=None): + """this query_relations docstring should find its way to docs""" + raise NotImplementedError + + @redirect_operation(capability=_MyCapability.PUT_IT) + def url_for_put(self, checksum_iri): + """this url_for_put docstring should find its way to docs""" + raise NotImplementedError + + ### + # implement (some of) the interface's declared operations + + class _MyImplementation(_MyInterface): + def url_for_get(self, checksum_iri) -> str: + """this url_for_get docstring could contain implementation-specific caveats""" + return f"https://myarchive.example///{checksum_iri}" + + def url_for_put(self, checksum_iri): + """this url_for_put docstring could contain implementation-specific caveats""" + # TODO: how to represent "send a PUT request here"? + # return RedirectLadle( + # HTTPMethod.PUT, + # f'https://myarchive.example///{checksum_iri}', + # )? + return f"https://myarchive.example///{checksum_iri}" + + cls._MyCapability = _MyCapability + cls._MyInterface = _MyInterface + cls._MyImplementation = _MyImplementation + + cls._expected_get_op = AddonOperationDeclaration( + operation_type=AddonOperationType.REDIRECT, + capability=_MyCapability.GET_IT, + operation_fn=_MyInterface.url_for_get, + ) + cls._expected_put_op = AddonOperationDeclaration( + operation_type=AddonOperationType.REDIRECT, + capability=_MyCapability.PUT_IT, + operation_fn=_MyInterface.url_for_put, + ) + cls._expected_query_op = AddonOperationDeclaration( + operation_type=AddonOperationType.PROXY, + capability=_MyCapability.GET_IT, + operation_fn=_MyInterface.query_relations, + ) + + cls._expected_get_imp = AddonOperationImplementation( + operation=cls._expected_get_op, + implementation_cls=_MyImplementation, + ) + cls._expected_put_imp = AddonOperationImplementation( + operation=cls._expected_put_op, + implementation_cls=_MyImplementation, + ) + + def test_get_operation_declarations(self): + self.assertEqual( + set(get_operation_declarations(self._MyInterface)), + {self._expected_get_op, self._expected_put_op, self._expected_query_op}, + ) + self.assertEqual( + set( + get_operation_declarations( + self._MyInterface, capability=self._MyCapability.GET_IT + ) + ), + {self._expected_get_op, self._expected_query_op}, + ) + self.assertEqual( + set( + get_operation_declarations( + self._MyInterface, capability=self._MyCapability.PUT_IT + ) + ), + {self._expected_put_op}, + ) + self.assertEqual( + set( + get_operation_declarations( + self._MyInterface, capability=self._MyCapability.UNUSED + ) + ), + set(), + ) + + def test_get_implemented_operations(self): + self.assertEqual( + set(get_operation_implementations(self._MyImplementation)), + {self._expected_get_imp, self._expected_put_imp}, + ) + self.assertEqual( + set( + get_operation_implementations( + self._MyImplementation, + capability=self._MyCapability.GET_IT, + ) + ), + {self._expected_get_imp}, + ) + self.assertEqual( + set( + get_operation_implementations( + self._MyImplementation, + capability=self._MyCapability.PUT_IT, + ) + ), + {self._expected_put_imp}, + ) + self.assertEqual( + set( + get_operation_implementations( + self._MyImplementation, + capability=self._MyCapability.UNUSED, + ) + ), + set(), + ) diff --git a/addon_toolkit/tests/test_declarator.py b/addon_toolkit/tests/test_declarator.py new file mode 100644 index 00000000..d565b804 --- /dev/null +++ b/addon_toolkit/tests/test_declarator.py @@ -0,0 +1,5 @@ +import addon_toolkit.declarator +from addon_toolkit.tests._doctest import load_doctests + + +load_tests = load_doctests(addon_toolkit.declarator)