diff --git a/addon_service/addon_imp/models.py b/addon_service/addon_imp/models.py index ac631c70..fa3a89e6 100644 --- a/addon_service/addon_imp/models.py +++ b/addon_service/addon_imp/models.py @@ -39,7 +39,7 @@ def implemented_operations(self) -> list: from addon_service.addon_operation.models import AddonOperationModel return [ - AddonOperationModel.for_operation_imp(_op_imp) + AddonOperationModel(_op_imp) for _op_imp in AddonOperationImplementation.on_implementation_cls( self.imp_cls ) diff --git a/addon_service/addon_operation/models.py b/addon_service/addon_operation/models.py index 34c466c4..bc6759c4 100644 --- a/addon_service/addon_operation/models.py +++ b/addon_service/addon_operation/models.py @@ -5,42 +5,46 @@ from addon_service.addon_imp.models import AddonImpModel from addon_service.common.dataclass_model import BaseDataclassModel from addon_toolkit import AddonOperationImplementation -from addon_toolkit.dataclass_json import jsonschema_for_dataclass +from addon_toolkit.json_arguments import jsonschema_for_signature_params from addon_toolkit.operation import AddonOperationType -from addon_toolkit.storage import StorageCapability # not a database model; just a convenience wrapper for # AddonOperationImplementation to give the serializer @dataclasses.dataclass class AddonOperationModel(BaseDataclassModel): - name: str - operation_type: AddonOperationType - docstring: str - implementation_docstring: str - capability: enum.Enum - imp_cls: type - params_dataclass: type + imp: AddonOperationImplementation - @classmethod - def for_operation_imp( - cls, op_imp: AddonOperationImplementation - ) -> "AddonOperationModel": - return cls( - name=op_imp.operation.name, - operation_type=op_imp.operation.operation_type, - docstring=op_imp.operation.docstring, - implementation_docstring=op_imp.implementation_docstring, - capability=StorageCapability(op_imp.operation.capability), - imp_cls=op_imp.implementation_cls, - params_dataclass=op_imp.operation.params_dataclass, - ) + @property + def name(self) -> str: + return self.imp.operation.name + + @property + def operation_type(self) -> AddonOperationType: + return self.imp.operation.operation_type + + @property + def docstring(self) -> str: + return self.imp.operation.docstring + + @property + def implementation_docstring(self) -> str: + return self.imp.implementation_docstring + + @property + def capability(self) -> enum.Enum: + # TODO: any known capability? + return self.imp.operation.capability + + @property + def imp_cls(self) -> type: + return self.imp.implementation_cls @classmethod def get_by_natural_key(cls, key: list) -> "AddonOperationModel": _imp_name, _operation_name = key _imp_cls = KnownAddonImp[_imp_name].value # TODO: try, 404 - return cls.for_operation_imp( + return cls( AddonOperationImplementation.by_operation_name( _imp_cls, _operation_name, @@ -53,12 +57,12 @@ def natural_key(self) -> list: return [_imp_name, self.name] @property - def implemented_by(self): + def implemented_by(self) -> AddonImpModel: return AddonImpModel.for_imp(KnownAddonImp(self.imp_cls)) @property def params_jsonschema(self) -> dict: - return jsonschema_for_dataclass(self.params_dataclass) + return jsonschema_for_signature_params(self.imp.operation.signature) class JSONAPIMeta: resource_name = "addon-operations" diff --git a/addon_service/addon_operation_invocation/models.py b/addon_service/addon_operation_invocation/models.py index 0c906f41..7be83a98 100644 --- a/addon_service/addon_operation_invocation/models.py +++ b/addon_service/addon_operation_invocation/models.py @@ -1,6 +1,9 @@ import jsonschema from django.core.exceptions import ValidationError -from django.db import models +from django.db import ( + models, + transaction, +) from addon_service.addon_imp.known import KnownAddonImp from addon_service.common.base_model import AddonsServiceBaseModel @@ -8,7 +11,7 @@ from addon_service.common.invocation import IntInvocationStatus from addon_service.models import AddonOperationModel from addon_toolkit import AddonOperationImplementation -from addon_toolkit.dataclass_json import json_for_dataclass +from addon_toolkit.json_arguments import json_for_dataclass class AddonOperationInvocation(AddonsServiceBaseModel): @@ -17,7 +20,7 @@ class AddonOperationInvocation(AddonsServiceBaseModel): default=IntInvocationStatus.STARTING, ) operation_identifier = models.TextField() - operation_params = models.JSONField(default=dict, blank=True) + operation_kwargs = models.JSONField(default=dict, blank=True) thru_addon = models.ForeignKey("ConfiguredStorageAddon", on_delete=models.CASCADE) by_user = models.ForeignKey("UserReference", on_delete=models.CASCADE) operation_result = models.JSONField(null=True, default=None, blank=True) @@ -32,12 +35,12 @@ class JSONAPIMeta: @property def operation(self) -> AddonOperationModel: - return AddonOperationModel.for_operation_imp(self.operation_imp) + return AddonOperationModel(self.operation_imp) @property def operation_imp(self) -> AddonOperationImplementation: return AddonOperationImplementation.by_operation_name( - imp_cls=self.known_addon_imp.value, + imp_cls=self.imp_cls, op_name=self.operation_identifier, ) @@ -45,26 +48,37 @@ def operation_imp(self) -> AddonOperationImplementation: def known_addon_imp(self) -> KnownAddonImp: return self.thru_addon.base_account.external_storage_service.known_addon_imp + @property + def imp_cls(self) -> type: + return self.known_addon_imp.value + def clean(self): try: jsonschema.validate( - instance=self.operation_params, + instance=self.operation_kwargs, schema=self.operation.params_jsonschema, ) except jsonschema.exceptions.ValidationError as _error: raise ValidationError(_error) - def execute(self): + def execute(self): # TODO: async_execute? with dibs(self): try: - # TODO: what if this raises database error? - _result = self.operation_imp.call_with_params(self.operation_params) + # wrap in a transaction to contain database errors, + # so status can be saved in the outer transaction + with transaction.atomic(): + _result = self.operation_imp.call_with_kwargs( + self.imp_cls(), # TODO: allow imp init params + **self.operation_kwargs, + ) except Exception as _e: self.operation_result = None self.invocation_status = IntInvocationStatus.PROBLEM print(_e) - # TODO: error/traceback - else: + # TODO: save message/traceback + raise + else: # no errors self.operation_result = json_for_dataclass(_result) self.invocation_status = IntInvocationStatus.SUCCESS - self.save() # TODO: finally? + finally: + self.save() diff --git a/addon_service/addon_operation_invocation/serializers.py b/addon_service/addon_operation_invocation/serializers.py index d17f758f..a50200fb 100644 --- a/addon_service/addon_operation_invocation/serializers.py +++ b/addon_service/addon_operation_invocation/serializers.py @@ -27,7 +27,7 @@ class Meta: fields = [ "url", "invocation_status", - "operation_params", + "operation_kwargs", "operation_result", "operation", "by_user", @@ -45,7 +45,7 @@ class Meta: internal_enum=IntInvocationStatus, external_enum=InvocationStatus, ) - operation_params = serializers.JSONField() + operation_kwargs = serializers.JSONField() operation_result = serializers.JSONField(read_only=True) created = serializers.DateTimeField(read_only=True) modified = serializers.DateTimeField(read_only=True) @@ -77,13 +77,13 @@ def create(self, validated_data): _operation = validated_data["operation"] _invocation = AddonOperationInvocation.objects.create( operation_identifier=_operation.name, - operation_params=validated_data["operation_params"], + operation_kwargs=validated_data["operation_kwargs"], thru_addon=validated_data["thru_addon"], by_user=UserReference.objects.all().first(), # TODO: infer user from request! ) match _operation.operation_type: case AddonOperationType.REDIRECT | AddonOperationType.IMMEDIATE: - _invocation.execute() + _invocation.execute() # block until done case AddonOperationType.EVENTUAL: raise NotImplementedError("TODO: enqueue task") case _: diff --git a/addon_service/authorized_storage_account/models.py b/addon_service/authorized_storage_account/models.py index 86087f7f..b4fed16f 100644 --- a/addon_service/authorized_storage_account/models.py +++ b/addon_service/authorized_storage_account/models.py @@ -49,7 +49,7 @@ def authorized_capabilities_enum(self) -> list[enum.Enum]: @property def authorized_operations(self) -> list[AddonOperationModel]: return [ - AddonOperationModel.for_operation_imp(_imp) + AddonOperationModel(_imp) for _imp in AddonOperationImplementation.on_implementation_cls( self.external_storage_service.addon_imp.imp_cls, capabilities=self.authorized_capabilities_enum, diff --git a/addon_service/configured_storage_addon/models.py b/addon_service/configured_storage_addon/models.py index 49abd09b..5f7748c2 100644 --- a/addon_service/configured_storage_addon/models.py +++ b/addon_service/configured_storage_addon/models.py @@ -50,7 +50,7 @@ def connected_capabilities_enum(self) -> list[enum.Enum]: @property def connected_operations(self) -> list[AddonOperationModel]: return [ - AddonOperationModel.for_operation_imp(_imp) + AddonOperationModel(_imp) for _imp in AddonOperationImplementation.on_implementation_cls( self.base_account.external_storage_service.addon_imp.imp_cls, capabilities=self.connected_capabilities_enum, diff --git a/addon_service/management/commands/fill_garbage.py b/addon_service/management/commands/fill_garbage.py index 41f11c2a..5d86e8cf 100644 --- a/addon_service/management/commands/fill_garbage.py +++ b/addon_service/management/commands/fill_garbage.py @@ -54,7 +54,7 @@ def handle_label(self, label, **options): _soi = db.AddonOperationInvocation.objects.create( invocation_status=IntInvocationStatus.STARTING, operation_identifier=_op.natural_key_str, - operation_params={"item_id": "foo"}, + operation_kwargs={"item_id": "foo"}, thru_addon=_csa, by_user=_iu, ) diff --git a/addon_service/migrations/0001_initial.py b/addon_service/migrations/0001_initial.py index 0919d420..971f3ebd 100644 --- a/addon_service/migrations/0001_initial.py +++ b/addon_service/migrations/0001_initial.py @@ -303,7 +303,7 @@ class Migration(migrations.Migration): ), ), ("operation_identifier", models.TextField()), - ("operation_params", models.JSONField(blank=True, default=dict)), + ("operation_kwargs", models.JSONField(blank=True, default=dict)), ( "operation_result", models.JSONField(blank=True, default=None, null=True), diff --git a/addon_service/tests/_factories.py b/addon_service/tests/_factories.py index d8cb7f00..a9e8acbf 100644 --- a/addon_service/tests/_factories.py +++ b/addon_service/tests/_factories.py @@ -41,6 +41,18 @@ class Meta: credentials = factory.SubFactory(ExternalCredentialsFactory) +class AddonOperationInvocationFactory(DjangoModelFactory): + class Meta: + model = db.AddonOperationInvocation + + operation_identifier = "download" + operation_kwargs = {"item_id": "foo"} + thru_addon = factory.SubFactory( + "addon_service.tests._factories.ConfiguredStorageAddonFactory" + ) + by_user = factory.SubFactory(UserReferenceFactory) + + ### # "Storage" models diff --git a/addon_service/tests/test_by_type/test_addon_operation_invocation.py b/addon_service/tests/test_by_type/test_addon_operation_invocation.py index 716ea93b..f1939136 100644 --- a/addon_service/tests/test_by_type/test_addon_operation_invocation.py +++ b/addon_service/tests/test_by_type/test_addon_operation_invocation.py @@ -17,7 +17,7 @@ ) -class TestAddonOperationInvocationAPI(APITestCase): +class TestAddonOperationInvocationCreate(APITestCase): @classmethod def setUpTestData(cls): cls._configured_addon = _factories.ConfiguredStorageAddonFactory() @@ -25,31 +25,16 @@ def setUpTestData(cls): "blarg:download" ) - def _detail_path(self, invocation: models.AddonOperationInvocation): - return reverse( - "addon-operation-invocations-detail", - kwargs={"pk": invocation.pk}, - ) - @property def _list_path(self): return reverse("addon-operation-invocations-list") - def _related_path(self, invocation, related_field): - return reverse( - "addon-operation-invocations-related", - kwargs={ - "pk": invocation.pk, - "related_field": related_field, - }, - ) - def _payload_for_post(self): return { "data": { "type": "addon-operation-invocations", "attributes": { - "operation_params": {"item_id": "foo"}, + "operation_kwargs": {"item_id": "foo"}, }, "relationships": { "operation": { @@ -81,14 +66,40 @@ def test_post(self): "success", ) - @unittest.skip("TODO") + +@unittest.skip("TODO") +class TestAddonOperationInvocationErrors(APITestCase): + @classmethod + def setUpTestData(cls): + cls._invocation = _factories.AddonOperationInvocationFactory() + + @property + def _detail_path(self): + return reverse( + "addon-operation-invocations-detail", + kwargs={"pk": self._invocation.pk}, + ) + + @property + def _list_path(self): + return reverse("addon-operation-invocations-list") + + def _related_path(self, related_field): + return reverse( + "addon-operation-invocations-related", + kwargs={ + "pk": self._invocation.pk, + "related_field": related_field, + }, + ) + def test_methods_not_allowed(self): _methods_not_allowed = { - self._detail_path: {"post"}, - # TODO: self._list_path: {'get', 'patch', 'put', 'post'}, - self._related_path("account_owner"): {"patch", "put", "post"}, - self._related_path("external_storage_service"): {"patch", "put", "post"}, - self._related_path("configured_storage_addons"): {"patch", "put", "post"}, + self._list_path: {"get", "patch", "put"}, + self._detail_path: {"patch", "put", "post", "delete"}, + self._related_path("thru_addon"): {"patch", "put", "post", "delete"}, + self._related_path("by_user"): {"patch", "put", "post", "delete"}, + self._related_path("operation"): {"patch", "put", "post", "delete"}, } for _path, _methods in _methods_not_allowed.items(): for _method in _methods: diff --git a/addon_toolkit/__init__.py b/addon_toolkit/__init__.py index c4656e7a..8d8b02f1 100644 --- a/addon_toolkit/__init__.py +++ b/addon_toolkit/__init__.py @@ -1,4 +1,3 @@ -from .imp import AddonOperationImplementation from .interface import ( AddonInterfaceDeclaration, addon_interface, @@ -9,6 +8,7 @@ immediate_operation, redirect_operation, ) +from .operation_imp import AddonOperationImplementation __all__ = ( diff --git a/addon_toolkit/dataclass_json.py b/addon_toolkit/dataclass_json.py deleted file mode 100644 index 56cb4727..00000000 --- a/addon_toolkit/dataclass_json.py +++ /dev/null @@ -1,90 +0,0 @@ -import dataclasses -import enum - - -def jsonschema_for_dataclass(dataclass: type) -> dict: - # TODO: required/optional fields - return { - "type": "object", - "properties": { - _field.name: jsonschema_for_field_type(_field.type) - for _field in dataclasses.fields(dataclass) - }, - "required": [ - _field.name - for _field in dataclasses.fields(dataclass) - if _is_field_required(_field) - ], - } - - -def jsonschema_for_field_type(field_type: type) -> dict: - if dataclasses.is_dataclass(field_type): - return jsonschema_for_dataclass(field_type) - if issubclass(field_type, enum.Enum): - return {"enum": [_item.value for _item in field_type]} - if field_type is str: - return {"type": "string"} - if field_type in (int, float): - return {"type": "number"} - if field_type in (tuple, list, set, frozenset): - return {"type": "list"} - raise NotImplementedError(f"what do with {field_type=}") - - -def json_for_dataclass(dataclass_instance) -> dict: - """return json-serializable representation of the dataclass instance""" - return { - _field.name: json_for_field_value(_field, dataclass_instance) - for _field in dataclasses.fields(dataclass_instance) - } - - -def json_for_field_value(field: dataclasses.Field, obj: object): - """return json-serializable representation of field value""" - _field_value = getattr(obj, field.name, None) - if _field_value is None: - return None # TODO: check optional - if dataclasses.is_dataclass(field.type): - assert isinstance(_field_value, field.type) - return json_for_dataclass(_field_value) - if issubclass(field.type, enum.Enum): - return _field_value.value - if field.type in (tuple, list, set, frozenset): - return list(_field_value) - if field.type in (str, int, float): - return _field_value - raise NotImplementedError(f"what do with {_field_value=} (value for {field})") - - -def dataclass_from_json(dataclass: type, dataclass_json: dict): - return dataclass( - **{ - _field.name: field_value_from_json(_field, dataclass_json) - for _field in dataclasses.fields(dataclass) - } - ) - - -def field_value_from_json(field: dataclasses.Field, dataclass_json: dict): - _json_value = dataclass_json.get(field.name) - if _json_value is None: - return None # TODO: check optional - if dataclasses.is_dataclass(field.type): - assert isinstance(_json_value, dict) - return dataclass_from_json(field.type, _json_value) - if issubclass(field.type, enum.Enum): - return field.type(_json_value) - if field.type in (tuple, list, set, frozenset): - return field.type(_json_value) - if field.type in (str, int, float): - assert isinstance(_json_value, field.type) - return _json_value - raise NotImplementedError(f"what do with {_json_value=} (value for {field})") - - -def _is_field_required(field: dataclasses.Field): - return ( - field.default_factory is dataclasses.MISSING - and field.default is dataclasses.MISSING - ) diff --git a/addon_toolkit/declarator.py b/addon_toolkit/declarator.py index 3ef114a0..225aaf74 100644 --- a/addon_toolkit/declarator.py +++ b/addon_toolkit/declarator.py @@ -97,6 +97,50 @@ class ClassDeclarator(Declarator): (same as Declarator but with additional methods that only make sense when used to decorate classes, and allow for inheritance and class instances) + + example declaration dataclass: + >>> @dataclasses.dataclass + ... class SemanticVersionDeclaration: + ... major: int + ... minor: int + ... patch: int + ... subj: type + + with shorthand declarator: + >>> semver = ClassDeclarator(SemanticVersionDeclaration, object_field='subj') + + for declarating classes: + >>> @semver( + ... major=4, + ... minor=2, + ... patch=9, + ... ) + ... class MyLongLivedBaseClass: + ... pass + + can get that declaration on the decorated class directly + >>> semver.get_declaration(MyLongLivedBaseClass) + SemanticVersionDeclaration(major=4, minor=2, patch=9, subj=) + + but `get_declaration` recognizes only the exact decorated object, not an instance or subclass: + >>> semver.get_declaration(MyLongLivedBaseClass()) + Traceback (most recent call last): + ... + ValueError: no declaration found for + >>> class Foo(MyLongLivedBaseClass): + ... pass + >>> semver.get_declaration(Foo) + Traceback (most recent call last): + ... + ValueError: no declaration found for + + to recognize a subclass of a declarated class, use `get_declaration_for_class` (returns the first declaration found on items in `__mro__`) + >>> semver.get_declaration_for_class(Foo) + SemanticVersionDeclaration(major=4, minor=2, patch=9, subj=) + + to also recognize an instance of a class, use `get_declaration_for_class_or_instance`: + >>> semver.get_declaration_for_class_or_instance(Foo()) + SemanticVersionDeclaration(major=4, minor=2, patch=9, subj=) """ def get_declaration_for_class_or_instance(self, type_or_object: type | object): diff --git a/addon_toolkit/json_arguments.py b/addon_toolkit/json_arguments.py new file mode 100644 index 00000000..29e08814 --- /dev/null +++ b/addon_toolkit/json_arguments.py @@ -0,0 +1,181 @@ +import dataclasses +import enum +import inspect +import types +import typing + + +__all__ = ( + "bound_args_from_json", + "dataclass_from_json", + "json_for_arguments", + "json_for_dataclass", + "json_for_typed_value", + "jsonschema_for_annotation", + "jsonschema_for_dataclass", + "jsonschema_for_signature_params", +) + + +def jsonschema_for_signature_params(signature: inspect.Signature) -> dict: + """build jsonschema corresponding to parameters from a function signature""" + # TODO: required/optional fields + return { + "type": "object", + "properties": { + _param_name: jsonschema_for_annotation(_param.annotation) + for (_param_name, _param) in signature.parameters.items() + if _param_name != "self" + }, + "required": [ + _param_name + for (_param_name, _param) in signature.parameters.items() + if _param_name != "self" and _param.default is inspect.Parameter.empty + ], + } + + +def jsonschema_for_dataclass(dataclass: type) -> dict: + """build jsonschema corresponding to fields in a dataclass""" + # TODO: required/optional fields + return { + "type": "object", + "properties": { + _field.name: jsonschema_for_annotation(_field.type) + for _field in dataclasses.fields(dataclass) + }, + "required": [ + _field.name + for _field in dataclasses.fields(dataclass) + if _is_field_required(_field) + ], + } + + +def jsonschema_for_annotation(annotation: type) -> dict: + """build jsonschema for a python type annotation""" + if dataclasses.is_dataclass(annotation): + return jsonschema_for_dataclass(annotation) + if issubclass(annotation, enum.Enum): + return {"enum": [_item.value for _item in annotation]} + if annotation is str: + return {"type": "string"} + if annotation in (int, float): + return {"type": "number"} + if annotation in (tuple, list, set, frozenset): + return {"type": "list"} + raise NotImplementedError(f"what do with param annotation '{annotation}'?") + + +def json_for_arguments(bound_args: inspect.BoundArguments) -> dict: + """return json-serializable representation of the dataclass instance""" + return { + _param_name: json_for_typed_value( + bound_args.signature.parameters[_param_name].annotation, + _arg_value, + ) + for (_param_name, _arg_value) in bound_args.arguments.items() + } + + +def json_for_typed_value(annotation: type, value: typing.Any): + """return json-serializable representation of field value""" + if value is None: + return None # TODO: check optional? + if dataclasses.is_dataclass(annotation): + if not isinstance(value, annotation): + raise ValueError(f"expected instance of {annotation}, got {value}") + return json_for_dataclass(value) + if issubclass(annotation, enum.Enum): + if value not in annotation: + raise ValueError(f"expected member of enum {annotation}, got {value}") + return value.value + if annotation in (str, int, float): # check str before Iterable + return value + if isinstance(annotation, typing.Iterable): + if not isinstance(annotation, types.GenericAlias): + return list(value) + # parameterized generic type, e.g. list[int] + try: + (_item_annotation,) = annotation.__args__ + except ValueError: + raise ValueError( + f"expected exactly one type param, got {len(annotation.__args__)} (on {annotation})" + ) + else: + return [ + json_for_typed_value(_item_annotation, _item_value) + for _item_value in value + ] + raise NotImplementedError(f"what do with argument type {annotation}? ({value=})") + + +def bound_args_from_json( + signature: inspect.Signature, args_from_json: dict +) -> inspect.BoundArguments: + _kwargs = { + _param_name: arg_value_from_json(_param, args_from_json.get(_param_name)) + for (_param_name, _param) in signature.parameters.items() + } + return signature.bind(**_kwargs) + + +def arg_value_from_json( + param: inspect.Parameter, json_arg_value: typing.Any +) -> typing.Any: + if json_arg_value is None: + return None # TODO: check optional + if dataclasses.is_dataclass(param.annotation): + assert isinstance(json_arg_value, dict) + return dataclass_from_json(param.annotation, json_arg_value) + if issubclass(param.annotation, enum.Enum): + return param.annotation(json_arg_value) + if param.annotation in (tuple, list, set, frozenset): + return param.annotation(json_arg_value) + if param.annotation in (str, int, float): + assert isinstance(json_arg_value, param.annotation) + return json_arg_value + raise NotImplementedError(f"what do with `{json_arg_value}` (value for {param})") + + +def json_for_dataclass(dataclass_instance) -> dict: + """return json-serializable representation of the dataclass instance""" + return { + _field.name: json_for_typed_value( + _field.type, getattr(dataclass_instance, _field.name) + ) + for _field in dataclasses.fields(dataclass_instance) + } + + +def dataclass_from_json(dataclass: type, dataclass_json: dict): + return dataclass( + **{ + _field.name: field_value_from_json(_field, dataclass_json) + for _field in dataclasses.fields(dataclass) + } + ) + + +def field_value_from_json(field: dataclasses.Field, dataclass_json: dict): + _json_value = dataclass_json.get(field.name) + if _json_value is None: + return None # TODO: check optional + if dataclasses.is_dataclass(field.type): + assert isinstance(_json_value, dict) + return dataclass_from_json(field.type, _json_value) + if issubclass(field.type, enum.Enum): + return field.type(_json_value) + if field.type in (tuple, list, set, frozenset): + return field.type(_json_value) + if field.type in (str, int, float): + assert isinstance(_json_value, field.type) + return _json_value + raise NotImplementedError(f"what do with {_json_value=} (value for {field})") + + +def _is_field_required(field: dataclasses.Field): + return ( + field.default_factory is dataclasses.MISSING + and field.default is dataclasses.MISSING + ) diff --git a/addon_toolkit/operation.py b/addon_toolkit/operation.py index eed38cbd..cbc6aedd 100644 --- a/addon_toolkit/operation.py +++ b/addon_toolkit/operation.py @@ -2,10 +2,7 @@ import enum import inspect from http import HTTPMethod -from typing import ( - Any, - Callable, -) +from typing import Callable from .declarator import Declarator @@ -36,20 +33,18 @@ class AddonOperationDeclaration: capability: enum.Enum operation_fn: Callable # the decorated function return_dataclass: type = dataclasses.field(default=type(None), compare=False) - params_dataclass: type = dataclasses.field(init=False, compare=False) @classmethod def for_function(self, fn: Callable) -> "AddonOperationDeclaration": return _operation_declarator.get_declaration(fn) def __post_init__(self): - # use __setattr__ to ignore dataclass frozenness - super().__setattr__("params_dataclass", self._build_params_dataclass()) _return_type = self.get_return_type() if self.return_dataclass is type(None): assert dataclasses.is_dataclass( _return_type ), f"operation methods must return a dataclass (got {_return_type} on {self.operation_fn})" + # use __setattr__ to ignore dataclass frozenness super().__setattr__("return_dataclass", _return_type) else: assert dataclasses.is_dataclass( @@ -70,21 +65,12 @@ def docstring(self) -> str: # TODO: consider docstring param on operation decorators, allow overriding __doc__ return self.operation_fn.__doc__ or "" + @property + def signature(self) -> inspect.Signature: + return inspect.signature(self.operation_fn) + def get_return_type(self) -> type: - return inspect.get_annotations(self.operation_fn)["return"] - - def get_param_types(self) -> tuple[tuple[str, Any], ...]: - return tuple( - (_name, _type) - for _name, _type in inspect.get_annotations(self.operation_fn).items() - if _name != "return" - ) - - def _build_params_dataclass(self): - return dataclasses.make_dataclass( - f"ParamsDataclass__{self.__class__.__name__}__{self.name}", - fields=self.get_param_types(), - ) + return self.signature.return_annotation # declarator for all types of operations -- use operation_type-specific decorators below diff --git a/addon_toolkit/imp.py b/addon_toolkit/operation_imp.py similarity index 77% rename from addon_toolkit/imp.py rename to addon_toolkit/operation_imp.py index a697a527..c49c5641 100644 --- a/addon_toolkit/imp.py +++ b/addon_toolkit/operation_imp.py @@ -74,19 +74,14 @@ def implementation_fn(self) -> Callable: def implementation_docstring(self) -> str: return self.implementation_fn.__doc__ or "" - def get_callable_for(self, addon_instance: object) -> Callable: - return getattr(addon_instance, self.method_name) + def call_with_kwargs(self, addon_instance: object, /, **kwargs): + _method = getattr(addon_instance, self.method_name) + _result = _method(**kwargs) # TODO: if async, use async_to_sync + assert isinstance(_result, self.operation.return_dataclass) + return _result - def call_with_params(self, params: dict, *, addon_instance=None): - _addon_instance = ( - # TODO: either document no-param __init__ for addon interface subclasses - # or add a way to get addon interface instances with varying params - self.implementation_cls() - if addon_instance is None - else addon_instance - ) - _method = self.get_callable_for(_addon_instance) - # TODO: consider validating params against self.operation.params_dataclass? - _result = _method(**params) # TODO: reconsider + async def async_call_with_kwargs(self, addon_instance: object, /, **kwargs): + _method = getattr(addon_instance, self.method_name) + _result = await _method(**kwargs) # TODO: if not async, use sync_to_async assert isinstance(_result, self.operation.return_dataclass) return _result diff --git a/addon_toolkit/tests/test_dataclass_json.py b/addon_toolkit/tests/test_dataclass_json.py deleted file mode 100644 index d2d50cbd..00000000 --- a/addon_toolkit/tests/test_dataclass_json.py +++ /dev/null @@ -1,5 +0,0 @@ -import addon_toolkit.dataclass_json -from addon_toolkit.tests._doctest import load_doctests - - -load_tests = load_doctests(addon_toolkit.dataclass_json) diff --git a/addon_toolkit/tests/test_json_arguments.py b/addon_toolkit/tests/test_json_arguments.py new file mode 100644 index 00000000..9e83c8af --- /dev/null +++ b/addon_toolkit/tests/test_json_arguments.py @@ -0,0 +1,5 @@ +import addon_toolkit.json_arguments +from addon_toolkit.tests._doctest import load_doctests + + +load_tests = load_doctests(addon_toolkit.json_arguments)