diff --git a/docs/guides/compose-a-domain/register-elements.md b/docs/guides/compose-a-domain/register-elements.md index 62f7596a..23c8827f 100644 --- a/docs/guides/compose-a-domain/register-elements.md +++ b/docs/guides/compose-a-domain/register-elements.md @@ -34,7 +34,7 @@ documentation to understand the additional options supported by that element. You can also choose to register elements manually. -```python hl_lines="7-10 13" +```python hl_lines="8-11 14" {! docs_src/guides/composing-a-domain/014.py !} ``` diff --git a/docs/guides/domain-definition/events.md b/docs/guides/domain-definition/events.md index 1dfceafa..e6d2b5c3 100644 --- a/docs/guides/domain-definition/events.md +++ b/docs/guides/domain-definition/events.md @@ -46,10 +46,30 @@ and where any delays occur. An event's metadata provides additional context about the event. +Sample metadata from an event: + +```json +{ + "id": "test::user-411b2ceb-9513-45d7-9e03-bbc0846fae93-0", + "type": "Test.UserLoggedIn.v1", + "fqn": "tests.event.test_event_metadata.UserLoggedIn", + "kind": "EVENT", + "stream": "test::user-411b2ceb-9513-45d7-9e03-bbc0846fae93", + "origin_stream": null, + "timestamp": "2024-08-16 15:30:27.977101+00:00", + "version": "v1", + "sequence_id": "0", + "payload_hash": 2438879608558888394 +} +``` + #### `id` The unique identifier of the event. The event ID is a structured string, of the -format **....**. +format `::--`. + +The `id` value is simply an extension of the event's stream combined with the +`sequence_id`. Read the section on `sequence_id` to understand possible values. #### `type` @@ -58,31 +78,32 @@ For e.g. `Shipping.OrderShipped.v1`. #### `fqn` -The fully qualified name of the event. This is used internally by Protean -to resconstruct objects from messages. +Internal. The fully qualified name of the event. This is used by Protean to +resconstruct objects from messages. #### `kind` -Represents the kind of object enclosed in an event store message. Value is -`EVENT` for Events. `Metadata` class is shared between Events and Commands, so -possible values are `EVENT` and `COMMAND`. +Internal. Represents the kind of object enclosed in an event store message. +Value is `EVENT` for Events. `Metadata` class is shared between Events and +Commands, so possible values are `EVENT` and `COMMAND`. #### `stream` -Name of the event stream. E.g. Stream `user-1234` encloses messages related to -`User` aggregate with identity `1234`. +Name of the event stream. E.g. Stream `auth::user-1234` encloses messages +related to `User` aggregate in the `Auth` domain with identity `1234`. #### `origin_stream` -Name of the stream that originated this event or command. +Name of the stream that originated this event or command. `origin_stream` comes +handy when correlating related events or understanding causality. #### `timestamp` -The timestamp of event generation. +The timestamp of event generation in ISO 8601 format. #### `version` -The version of the event. +The version of the event class used to generate the event. #### `sequence_id` @@ -94,18 +115,26 @@ sequence ID of `1.1`, and the second update would have a sequence ID of `2.1`. If the next update generated two events, then the sequence ID of the second event would be `3.2`. +If the aggregate is event-sourced, the `sequence_id` is a single integer of the +position of the event in its stream. + #### `payload_hash` -The hash of the event's payload. +The `payload_hash` serves as a unique fingerprint for the event's +[payload](#payload). It is generated by hashing the stringified event payload +json with sorted keys. + +`payload_hash` can be used to verify the integrity of the payload and in +implementing idempotent operations. ## Payload The payload is a dictionary of key-value pairs that convey the information about the event. -The payload is made available as the data in the event. If -you want to extract just the payload, you can use the `payload` property -of the event. +The payload is made available as the body of the event, which also includes +the event metadata. If you want to extract just the payload, you can use the +`payload` property of the event. ```shell hl_lines="22 24-25" In [1]: user = User(id="1", email="", name="") @@ -140,9 +169,10 @@ Out[6]: {'user_id': '1'} Because events serve as API contracts of an aggregate with the rest of the ecosystem, they are versioned to signal changes to contract. -By default, events have a version of **v1**. +Events have a default version of **v1**. -You can specify a version with the `__version__` class attribute: +You can override and customize the version with the `__version__` class +attribute: ```python hl_lines="3" @domain.event(part_of=User) diff --git a/docs/guides/object-model.md b/docs/guides/object-model.md index 9b194b9d..7ba9ffd2 100644 --- a/docs/guides/object-model.md +++ b/docs/guides/object-model.md @@ -1,69 +1,101 @@ # Object Model -DOCUMENTATION IN PROGRESS +Domain elements in Protean have a common structure and share a few behavioral +traits. -A domain model in Protean is composed with various types of domain elements, -all of which have a common structure and share a few behavioral traits. This -document outlines generic aspects that apply to every domain element. +## Meta Options -## `Element` Base class +Protean elements have a `meta_` attribute that holds the configuration options +specified for the element. -`Element` is a base class inherited by all domain elements. Currently, it does -not have any data structures or behavior associated with it. +Options are passed as parameters to the element decorator: -## Element Type +```python hl_lines="7" +{! docs_src/guides/composing-a-domain/021.py !} +``` -.element_type +```python +In [1]: User.meta_ +Out[1]: +{'model': None, + 'stream_category': 'user', + 'auto_add_id_field': True, + 'fact_events': False, + 'abstract': False, + 'schema_name': 'user', + 'aggregate_cluster': User, + 'is_event_sourced': False, + 'provider': 'default'} +``` -## Data Containers +### `abstract` -Protean provides data container elements, aligned with DDD principles to model -a domain. These containers hold the data that represents the core concepts -of the domain. +`abstract` is a common meta attribute available on all elements. An element +that is marked abstract cannot be instantiated. -There are three primary data container elements in Protean: +!!!note + Field orders are preserved in container elements. -- Aggregates: The root element that represents a consistent and cohesive -collection of related entities and value objects. Aggregates manage their -own data consistency and lifecycle. -- Entities: Unique and identifiable objects within your domain that have -a distinct lifecycle and behavior. Entities can exist independently but -are often part of an Aggregate. -- Value Objects: Immutable objects that encapsulate a specific value or -concept. They have no identity and provide a way to group related data -without independent behavior. +## Reflection -### Reflection +Protean provides reflection methods to explore container elements. Each of the +below methods accept a element or an instance of one. +### `has_fields` +Returns `True` if the element encloses fields. -## Metadata / Configuration Options +### `fields` -Additional options can be passed to a domain element in two ways: +Return a tuple of fields in the element, both explicitly defined and internally +added. -- **`Meta` inner class** +Raises `IncorrectUsageError` if called on non-container elements like +Application Services or Command Handlers. -You can specify options within a nested inner class called `Meta`: +### `declared_fields` -```python hl_lines="13-14" -{! docs_src/guides/composing-a-domain/020.py !} -``` +Return a tuple of the explicitly declared fields. -- **Decorator Parameters** +### `data_fields` -You can also pass options as parameters to the decorator: +Return a tuple describing the data fields in this element. Does not include +metadata. -```python hl_lines="7" -{! docs_src/guides/composing-a-domain/021.py !} -``` +Raises `IncorrectUsageError` if called on non-container elements like +Application Services or Command Handlers. +### `has_association_fields` -### `abstract` +Returns `True` if element contains associations. + +### `association_fields` + +Return a tuple of the association fields. + +Raises `IncorrectUsageError` if called on non-container elements. + +### `id_field` + +Return the identity field of this element, or `None` if there is no identity +field. + +### `has_id_field` + +Returns `True` if the element has an identity field. + +### `attributes` + +Internal. Returns a dictionary of fields that generate a representation of +data for external use. + +Attributes include simple field representations of complex fields like +value objects and associations. +Raises `IncorrectUsageError` if called on non-container elements -### `auto_add_id_field` +### `unique_fields` +Return fields marked as unique. -Abstract elements: -Most elements can be marked abstract to be subclassed. They cannot be instantiated -, but their field orders are preserved. Ex. Events. \ No newline at end of file +Raises `IncorrectUsageError` if called on non-container elements. diff --git a/docs_src/guides/composing-a-domain/014.py b/docs_src/guides/composing-a-domain/014.py index 46bd15e5..3e8dee9f 100644 --- a/docs_src/guides/composing-a-domain/014.py +++ b/docs_src/guides/composing-a-domain/014.py @@ -1,4 +1,5 @@ -from protean import BaseAggregate, Domain +from protean.core.aggregate import BaseAggregate +from protean.domain import Domain from protean.fields import Integer, String domain = Domain(__file__, load_toml=False) @@ -10,4 +11,4 @@ class User(BaseAggregate): age = Integer() -domain.register(User, stream_name="account") +domain.register(User, stream_category="account") diff --git a/docs_src/guides/composing-a-domain/015.py b/docs_src/guides/composing-a-domain/015.py index 7563ece9..b2327cf8 100644 --- a/docs_src/guides/composing-a-domain/015.py +++ b/docs_src/guides/composing-a-domain/015.py @@ -4,7 +4,7 @@ domain = Domain(__file__, load_toml=False) -@domain.aggregate(stream_name="account") +@domain.aggregate(stream_category="account") class User: first_name = String(max_length=50) last_name = String(max_length=50) diff --git a/docs_src/guides/composing-a-domain/020.py b/docs_src/guides/composing-a-domain/020.py index f5f07379..68b88250 100644 --- a/docs_src/guides/composing-a-domain/020.py +++ b/docs_src/guides/composing-a-domain/020.py @@ -4,14 +4,11 @@ domain = Domain(__file__, load_toml=False) -@domain.aggregate +@domain.aggregate(stream_category="account") class User: first_name = String(max_length=50) last_name = String(max_length=50) age = Integer() - class Meta: - stream_name = "account" - domain.register(User) diff --git a/docs_src/guides/composing-a-domain/021.py b/docs_src/guides/composing-a-domain/021.py index 0b45ba7d..68b88250 100644 --- a/docs_src/guides/composing-a-domain/021.py +++ b/docs_src/guides/composing-a-domain/021.py @@ -4,7 +4,7 @@ domain = Domain(__file__, load_toml=False) -@domain.aggregate(stream_name="account") +@domain.aggregate(stream_category="account") class User: first_name = String(max_length=50) last_name = String(max_length=50) diff --git a/docs_src/guides/consume-state/001.py b/docs_src/guides/consume-state/001.py index f1943ec2..bc8694b4 100644 --- a/docs_src/guides/consume-state/001.py +++ b/docs_src/guides/consume-state/001.py @@ -39,7 +39,7 @@ class Inventory: in_stock = Integer(required=True) -@domain.event_handler(part_of=Inventory, stream_name="order") +@domain.event_handler(part_of=Inventory, stream_category="order") class ManageInventory: @handle(OrderShipped) def reduce_stock_level(self, event: OrderShipped): diff --git a/docs_src/guides/consume-state/002.py b/docs_src/guides/consume-state/002.py index 78b3e20e..c98b4da4 100644 --- a/docs_src/guides/consume-state/002.py +++ b/docs_src/guides/consume-state/002.py @@ -110,7 +110,7 @@ def adjust_stock(self, command: AdjustStock): repository.add(product) -@domain.event_handler(stream_name="product") +@domain.event_handler(stream_category="product") class SyncInventory: @handle(ProductAdded) def on_product_added(self, event: ProductAdded): diff --git a/mkdocs.yml b/mkdocs.yml index 70fe3246..8d151c72 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -144,10 +144,13 @@ nav: - guides/domain-behavior/aggregate-mutation.md - guides/domain-behavior/raising-events.md - guides/domain-behavior/domain-services.md + + - Foundation: - guides/object-model.md - guides/identity.md - - App Layer: - guides/configuration.md + + - App Layer: - Changing State: - guides/change-state/index.md - guides/change-state/commands.md diff --git a/src/protean/core/serializer.py b/src/protean/core/serializer.py deleted file mode 100644 index 19c4c09c..00000000 --- a/src/protean/core/serializer.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Serializer Object Functionality and Classes""" - -import logging - -from marshmallow import Schema, fields - -from protean.exceptions import NotSupportedError -from protean.fields import ( - Boolean, - Date, - DateTime, - Dict, - Field, - Float, - Identifier, - Integer, - List, - Method, - Nested, - String, - Text, -) -from protean.utils import DomainObjects, derive_element_class -from protean.utils.container import Element, Options, OptionsMixin -from protean.utils.reflection import _FIELDS - -logger = logging.getLogger(__name__) - - -def derive_marshmallow_field_from(field_obj: Field): # noqa: C901 - if isinstance(field_obj, Boolean): - return fields.Boolean() - elif isinstance(field_obj, Date): - return fields.Date() - elif isinstance(field_obj, DateTime): - return fields.DateTime() - elif isinstance(field_obj, Identifier): - return fields.String() - elif isinstance(field_obj, String): - return fields.String() - elif isinstance(field_obj, Text): - return fields.String() - elif isinstance(field_obj, Integer): - return fields.Integer() - elif isinstance(field_obj, Float): - return fields.Float() - elif isinstance(field_obj, Method): - return fields.Method(field_obj.method_name) - elif isinstance(field_obj, List): - return fields.List( - # `field_obj.content_type` holds the class of the associated field, - # but this method works with objects (uses `isinstance`) - # - # We need to use `isinstance` because we need to pass the entire field - # object into this method, to be able to extract other attributes. - # - # So we instantiate the field in `field_obj.content_type` before calling - # the method. - derive_marshmallow_field_from(field_obj.content_type()) - ) - elif isinstance(field_obj, Dict): # FIXME Accept type param in Dict field - return fields.Dict(keys=fields.Str()) - elif isinstance(field_obj, Nested): - return fields.Nested(field_obj.schema_name, many=field_obj.many) - else: - raise NotSupportedError("{} Field not supported".format(type(field_obj))) - - -class _SerializerMetaclass(type): - """ - This base metaclass processes the class declaration and constructs a Marshmellow Class meta object that can - be used to load and dump data. It also sets up a `meta_` attribute on the Serializer to be an instance of Meta, - either the default or one that is defined in the Serializer class. - - `meta_` is setup with these attributes: - * `declared_fields`: A dictionary that gives a list of any instances of `Field` - included as attributes on either the class or on any of its superclasses - """ - - def __new__(mcs, name, bases, attrs, **kwargs): # noqa: C901 - """Initialize Serializer MetaClass and load attributes""" - - def _declared_base_class_fields(bases, attrs): - """If this class is subclassing another Serializer, add that Serializer's - fields. Note that we loop over the bases in *reverse*. - This is necessary in order to maintain the correct order of fields. - """ - declared_fields = {} - - for base in reversed(bases): - if hasattr(base, "meta_") and hasattr(base.meta_, "declared_fields"): - base_class_fields = { - field_name: field_obj - for ( - field_name, - field_obj, - ) in fields(base).items() - if field_name not in attrs and not field_obj.identifier - } - declared_fields.update(base_class_fields) - - return declared_fields - - def _declared_fields(attrs): - """Load field items into Class""" - declared_fields = {} - - for attr_name, attr_obj in attrs.items(): - if isinstance(attr_obj, Field): - declared_fields[attr_name] = attr_obj - - return declared_fields - - @classmethod - def _default_options(cls): - return [] - - # Ensure initialization is only performed for subclasses of Serializer - # (excluding Serializer class itself). - parents = [b for b in bases if isinstance(b, _SerializerMetaclass)] - if not parents: - return super().__new__(mcs, name, bases, attrs) - - # Load declared fields - declared_fields = _declared_fields(attrs) - - # Load declared fields from Base class, in case this Entity is subclassing another - base_class_fields = _declared_base_class_fields(bases, attrs) - - all_fields = {**declared_fields, **base_class_fields} - - schema_fields = {} - for field_name, field_obj in all_fields.items(): - schema_fields[field_name] = derive_marshmallow_field_from(field_obj) - - # Remove Protean fields from Serializer class - for field_name in schema_fields: - attrs.pop(field_name, None) - - # Update `attrs` with new marshmallow fields - attrs.update(schema_fields) - - # Remove `abstract` in base classes if defined - for base in bases: - if hasattr(base, "Meta") and hasattr(base.Meta, "abstract"): - delattr(base.Meta, "abstract") - - # Explicit redefinition element_type necessary because `attrs` - # are reset when a serializer class is initialized. - attrs["element_type"] = DomainObjects.SERIALIZER - attrs["_default_options"] = _default_options - - new_class = type(name, (Schema, Element, OptionsMixin), attrs) - - # Gather `Meta` class/object if defined - attr_meta = attrs.pop("Meta", None) - meta = attr_meta or getattr(new_class, "Meta", None) - setattr(new_class, "meta_", Options(meta)) - - setattr(new_class, _FIELDS, declared_fields) - - return new_class - - -class BaseSerializer(metaclass=_SerializerMetaclass): - """The Base class for Protean-Compliant Serializers. - - Provides helper methods to load and dump data during runtime, from protean entity objects. Core Protean - attributes like `element_type`, `meta_`, and `_default_options` are initialized in metaclass. - - Basic Usage:: - - @Serializer - class Address: - unit = field.String() - address = field.String(required=True, max_length=255) - city = field.String(max_length=50) - province = field.String(max_length=2) - pincode = field.String(max_length=6) - - (or) - - class Address(BaseSerializer): - unit = field.String() - address = field.String(required=True, max_length=255) - city = field.String(max_length=50) - province = field.String(max_length=2) - pincode = field.String(max_length=6) - - domain.register_element(Address) - """ - - def __new__(cls, *args, **kwargs): - if cls is BaseSerializer: - raise NotSupportedError("BaseSerializer cannot be instantiated") - return super().__new__(cls) - - @classmethod - def _default_options(cls): - return [] - - -def serializer_factory(element_cls, domain, **opts): - return derive_element_class(element_cls, BaseSerializer, **opts) diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index ceff1cb1..aa096e4e 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -404,7 +404,6 @@ def factory_for(self, domain_object_type): ) from protean.core.model import model_factory from protean.core.repository import repository_factory - from protean.core.serializer import serializer_factory from protean.core.subscriber import subscriber_factory from protean.core.value_object import value_object_factory from protean.core.view import view_factory @@ -423,7 +422,6 @@ def factory_for(self, domain_object_type): DomainObjects.MODEL.value: model_factory, DomainObjects.REPOSITORY.value: repository_factory, DomainObjects.SUBSCRIBER.value: subscriber_factory, - DomainObjects.SERIALIZER.value: serializer_factory, DomainObjects.VALUE_OBJECT.value: value_object_factory, DomainObjects.VIEW.value: view_factory, } @@ -960,9 +958,6 @@ def model(self, _cls=None, **kwargs): def repository(self, _cls=None, **kwargs): return self._domain_element(DomainObjects.REPOSITORY, _cls=_cls, **kwargs) - def serializer(self, _cls=None, **kwargs): - return self._domain_element(DomainObjects.SERIALIZER, _cls=_cls, **kwargs) - def subscriber(self, _cls=None, **kwargs): return self._domain_element( DomainObjects.SUBSCRIBER, diff --git a/src/protean/fields/base.py b/src/protean/fields/base.py index e95e8eb7..e3a80f60 100644 --- a/src/protean/fields/base.py +++ b/src/protean/fields/base.py @@ -187,7 +187,7 @@ def validators(self): return [*self.default_validators, *self._validators] @abstractmethod - def _cast_to_type(self, value: Any): + def _cast_to_type(self, value: Any) -> Any: """ Abstract method to validate and convert the value passed to native type. All subclasses must implement this method. @@ -195,7 +195,7 @@ def _cast_to_type(self, value: Any): """ @abstractmethod - def as_dict(self): + def as_dict(self, value: Any) -> Any: """Return JSON-compatible value of field""" def _run_validators(self, value): diff --git a/src/protean/utils/__init__.py b/src/protean/utils/__init__.py index 079660a0..7619098b 100644 --- a/src/protean/utils/__init__.py +++ b/src/protean/utils/__init__.py @@ -102,7 +102,6 @@ class DomainObjects(Enum): ENTITY = "ENTITY" MODEL = "MODEL" REPOSITORY = "REPOSITORY" - SERIALIZER = "SERIALIZER" SUBSCRIBER = "SUBSCRIBER" VALUE_OBJECT = "VALUE_OBJECT" VIEW = "VIEW" diff --git a/src/protean/utils/container.py b/src/protean/utils/container.py index a74f526b..5e3640d0 100644 --- a/src/protean/utils/container.py +++ b/src/protean/utils/container.py @@ -1,10 +1,8 @@ from __future__ import annotations -import copy -import inspect import logging from collections import defaultdict -from typing import Any, Union +from typing import Any from protean.exceptions import ( InvalidDataError, @@ -30,87 +28,60 @@ class Element: """Base class for all Protean elements""" -class Options: +class Options(dict): """Metadata info for the Container. Common options: - ``abstract``: Indicates that this is an abstract entity (Ignores all other meta options) """ - def __init__(self, opts: Union[dict, "Options"] = None) -> None: - self._opts = set() + def __init__(self, opts: dict[str, str | bool | None] | None = {}) -> None: + super().__init__() - if opts: - # FIXME Remove support passing a class as opts after revamping BaseSerializer - # The `inspect.isclass` check will not be necessary - if isinstance(opts, (self.__class__)) or inspect.isclass(opts): - attributes = inspect.getmembers( - opts, lambda a: not (inspect.isroutine(a)) - ) - for attr in attributes: - if not ( - attr[0].startswith("__") and attr[0].endswith("__") - ) and attr[0] not in ["_opts"]: - setattr(self, attr[0], attr[1]) - - self.abstract = getattr(opts, "abstract", None) or False - elif isinstance(opts, dict): - for opt_name, opt_value in opts.items(): - setattr(self, opt_name, opt_value) - - self.abstract = opts.get("abstract", None) or False - else: - raise ValueError( - f"Invalid options `{opts}` passed to Options. Must be a dict or Options instance." - ) + if opts is None: + opts = {} else: - # Common Meta attributes - self.abstract = getattr(opts, "abstract", None) or False - - def __setattr__(self, __name: str, __value: Any) -> None: - # Ignore if `_opts` is being set - if __name != "_opts": - self._opts.add(__name) - - super().__setattr__(__name, __value) - - def __delattr__(self, __name: str) -> None: - self._opts.discard(__name) - - super().__delattr__(__name) - - def __eq__(self, other) -> bool: - """Equivalence check based only on data.""" - if type(other) is not type(self): - return False - - return self.__dict__ == other.__dict__ - - def __hash__(self) -> int: - """Overrides the default implementation and bases hashing on values""" - filtered_dict = {k: v for k, v in self.__dict__.items() if k != "_opts"} - return hash(frozenset(filtered_dict.items())) - - def __add__(self, other: Options) -> None: - new_options = copy.copy(self) - for opt in other._opts: - setattr(new_options, opt, getattr(other, opt)) - + try: + opts = dict(opts) + except (TypeError, ValueError): + raise ValueError(f"Invalid options `{opts}`. Must be a dict.") + + self.update(opts) + self["abstract"] = opts.get("abstract", None) or False + + def __getattr__(self, name: str) -> Any: + try: + return self[name] + except KeyError: + raise AttributeError(f"'Options' object has no attribute '{name}'") + + def __setattr__(self, name: str, value: Any) -> None: + self[name] = value + + def __delattr__(self, name: str) -> None: + try: + del self[name] + except KeyError: + raise AttributeError(f"'Options' object has no attribute '{name}'") + + def __add__(self, other: "Options") -> "Options": + new_options = self.__class__(self) + new_options.update(other) return new_options class OptionsMixin: - def __init_subclass__(subclass) -> None: + def __init_subclass__(cls) -> None: """Setup Options metadata on elements Args: - subclass (Protean Element): Subclass to initialize with metadata + cls (Protean Element): Subclass to initialize with metadata """ - if not hasattr(subclass, "meta_"): - setattr(subclass, "meta_", Options()) + if not hasattr(cls, "meta_"): + setattr(cls, "meta_", Options()) # Assign default options - subclass._set_defaults() + cls._set_defaults() super().__init_subclass__() @@ -125,68 +96,37 @@ def _set_defaults(cls): setattr(cls.meta_, key, default) -class ContainerMeta(type): - """ - This base metaclass processes the class declaration and - constructs a meta object that can be used to introspect - the concrete Container class later. +class BaseContainer: + """The Base class for Protean-Compliant Data Containers. - It also sets up a `meta_` attribute on the concrete class - to an instance of Meta, either the default of one that is - defined in the concrete class. + Provides helper methods to custom define attributes, and find attribute names + during runtime. """ - def __new__(mcs, name, bases, attrs, **kwargs): - """Initialize Container MetaClass and load attributes""" + def __new__(cls, *args, **kwargs): + if cls is BaseContainer: + raise NotSupportedError("BaseContainer cannot be instantiated") + return super().__new__(cls) - # Ensure initialization is only performed for subclasses of Container - # (excluding Container class itself). - parents = [b for b in bases if isinstance(b, ContainerMeta)] - if not parents: - return super().__new__(mcs, name, bases, attrs) + def __init_subclass__(cls, **kwargs) -> None: + super().__init_subclass__(**kwargs) # Gather fields in the order specified, starting with base classes fields_dict = {} # ... from base classes first - for base in reversed(bases): + for base in reversed(cls.__bases__): if hasattr(base, _FIELDS): for field_name, field_obj in fields(base).items(): fields_dict[field_name] = field_obj # ... Apply own fields next - for attr_name, attr_obj in attrs.items(): + for attr_name, attr_obj in cls.__dict__.items(): if isinstance(attr_obj, FieldBase): fields_dict[attr_name] = attr_obj - # Gather all non-field attributes - dup_attrs = { - attr_name: attr_obj - for attr_name, attr_obj in attrs.items() - if attr_name not in fields_dict - } - - # Insert fields in the order in which they were specified - # When field names overlap, the last specified field wins - dup_attrs.update(fields_dict) - # Store fields in a special field for later reference - dup_attrs[_FIELDS] = fields_dict - - return super().__new__(mcs, name, bases, dup_attrs, **kwargs) - - -class BaseContainer(metaclass=ContainerMeta): - """The Base class for Protean-Compliant Data Containers. - - Provides helper methods to custom define attributes, and find attribute names - during runtime. - """ - - def __new__(cls, *args, **kwargs): - if cls is BaseContainer: - raise NotSupportedError("BaseContainer cannot be instantiated") - return super().__new__(cls) + setattr(cls, _FIELDS, fields_dict) def __init__(self, *template, **kwargs): # noqa: C901 """ diff --git a/src/protean/utils/reflection.py b/src/protean/utils/reflection.py index 59d4e927..8a270cd3 100644 --- a/src/protean/utils/reflection.py +++ b/src/protean/utils/reflection.py @@ -13,10 +13,9 @@ def fields(class_or_instance: Type[Element] | Element) -> dict[str, Field]: - """Return a tuple describing the fields of this dataclass. + """Return a dictionary of fields in this element. - Accepts a dataclass or an instance of one. Tuple elements are of - type Field. + Accepts an element or an instance of one. """ # Might it be worth caching this, per class? @@ -29,10 +28,9 @@ def fields(class_or_instance: Type[Element] | Element) -> dict[str, Field]: def data_fields(class_or_instance: Type[Element] | Element) -> dict[str, Field]: - """Return a tuple describing the data fields of this dataclass. + """Return a dictionary of data fields in this element. - Accepts a dataclass or an instance of one. Tuple elements are of - type Field. + Accepts an element or an instance of one. """ try: fields_dict = dict(getattr(class_or_instance, _FIELDS)) @@ -46,6 +44,7 @@ def data_fields(class_or_instance: Type[Element] | Element) -> dict[str, Field]: def id_field(class_or_instance: Type[Element] | Element) -> Field | None: + """Return the identity field in this element.""" try: field_name = getattr(class_or_instance, _ID_FIELD_NAME) except AttributeError: @@ -55,7 +54,7 @@ def id_field(class_or_instance: Type[Element] | Element) -> Field | None: def has_id_field(class_or_instance: Type[Element] | Element) -> bool: - """Check if class/instance has an identity attribute. + """Check if Element class/instance has an identity field. Args: class_or_instance (Any): Domain Element to check. @@ -67,11 +66,15 @@ def has_id_field(class_or_instance: Type[Element] | Element) -> bool: def has_fields(class_or_instance: Type[Element] | Element) -> bool: - """Check if Protean element encloses fields""" + """Check if the element encloses fields""" return hasattr(class_or_instance, _FIELDS) def attributes(class_or_instance: Type[Element] | Element) -> dict[str, Field]: + """Return a dictionary of attributes of this element. + + Accepts a element or an instance of one. + """ attributes_dict = {} for _, field_obj in fields(class_or_instance).items(): @@ -95,7 +98,7 @@ def attributes(class_or_instance: Type[Element] | Element) -> dict[str, Field]: def unique_fields(class_or_instance: Type[Element] | Element) -> dict[str, Field]: - """Return fields marked as unique for this class or instance""" + """Return a dictionary of fields marked `unique` in this class or instance""" return { field_name: field_obj for field_name, field_obj in attributes(class_or_instance).items() @@ -104,12 +107,11 @@ def unique_fields(class_or_instance: Type[Element] | Element) -> dict[str, Field def declared_fields(class_or_instance: Type[Element] | Element) -> dict[str, Field]: - """Return a tuple describing the declared fields of this dataclass. + """Return a dictionary of declared fields in this element. - Accepts a dataclass or an instance of one. Tuple elements are of - type Field. + Accepts a dataclass or an instance of one. - `_version` is a auto-controlled, internal field, so is not returned + `_version` is an auto-controlled, internal field, so is not returned among declared fields. """ @@ -127,9 +129,9 @@ def declared_fields(class_or_instance: Type[Element] | Element) -> dict[str, Fie def association_fields(class_or_instance: Type[Element] | Element) -> dict[str, Field]: - """Return a tuple describing the association fields of this dataclass. + """Return a dictionary of association fields in this elment. - Accepts an Entity. Tuple elements are of type Field. + Accepts an Element or an instance of one. """ from protean.fields.association import Association @@ -141,5 +143,5 @@ def association_fields(class_or_instance: Type[Element] | Element) -> dict[str, def has_association_fields(class_or_instance: Type[Element] | Element) -> bool: - """Check if Protean element encloses association fields""" + """Check if Element has association fields.""" return bool(association_fields(class_or_instance)) diff --git a/tests/container/test_options.py b/tests/container/test_options.py index d96d50c2..4e1f7855 100644 --- a/tests/container/test_options.py +++ b/tests/container/test_options.py @@ -1,62 +1,92 @@ +import pytest + from protean.utils.container import Options -class Meta: - foo = "bar" +@pytest.fixture +def opts_dict(): + return {"opt1": "value1", "opt2": "value2", "abstract": True} + + +@pytest.fixture +def opts_object(): + return Options({"opt1": "value1", "opt2": "value2", "abstract": True}) -def test_options_construction_from_meta_class(): - opts = Options(Meta) +def test_options_initialization(opts_dict, opts_object): + # Test initialization with a dictionary + options = Options(opts_dict) + assert options.opt1 == "value1" + assert options.opt2 == "value2" + assert options.abstract is True - assert opts is not None - assert opts.foo == "bar" + # Test initialization with an Options object + options = Options(opts_object) + assert options.opt1 == "value1" + assert options.opt2 == "value2" + assert options.abstract is True + # Test initialization with None + options = Options() + assert options.abstract is False -def test_options_construction_from_dict(): - opts = Options({"foo": "bar"}) + # Test initialization with an invalid type + with pytest.raises(ValueError): + Options("invalid") # type: ignore - This is expected to raise an exception - assert opts is not None - assert opts.foo == "bar" +def test_attribute_access_and_modification(opts_dict): + options = Options(opts_dict) -def test_tracking_currently_active_attributes(): - opts = Options({"foo": "bar"}) - assert opts._opts == {"abstract", "foo"} + # Test getattr + assert options.opt1 == "value1" - setattr(opts, "baz", "qux") - assert opts._opts == {"abstract", "foo", "baz"} + # Test setattr + options.opt3 = "value3" + assert options.opt3 == "value3" + assert "opt3" in options - opts.waldo = "fred" - assert opts._opts == {"abstract", "foo", "baz", "waldo"} + # Test delattr + del options.opt1 + assert not hasattr(options, "opt1") + assert "opt1" not in options - del opts.baz - assert opts._opts == {"abstract", "foo", "waldo"} +def test_options_equality(opts_dict): + # Test equality + options1 = Options(opts_dict) + options2 = Options(opts_dict) + assert options1 == options2 -def test_option_objects_equality(): - assert Options() == Options() - assert Options(Meta) == Options({"foo": "bar"}) + # Test inequality with different values + options2 = Options({"opt1": "value1", "opt2": "different_value", "abstract": True}) + assert options1 != options2 - assert Options({"foo": "bar"}) == Options({"foo": "bar"}) - assert Options({"foo": "bar"}) != Options({"foo": "baz"}) - class Meta2: - foo = "bar" +def test_merging_options(opts_dict): + options1 = Options(opts_dict) + options2 = Options({"opt3": "value3"}) - assert Options(Meta) == Options(Meta2) + # Test merging options + merged_options = options1 + options2 + assert merged_options.opt1 == "value1" + assert merged_options.opt2 == "value2" + assert merged_options.opt3 == "value3" + assert merged_options.abstract is False - class Meta3: - foo = "baz" + # Test that original options are not modified + assert not hasattr(options1, "opt3") - assert Options(Meta) != Options(Meta3) +def test_tracking_keys(opts_dict): + options = Options({"foo": "bar"}) + assert set(options.keys()) == {"abstract", "foo"} -def test_merging_two_option_objects(): - opt1 = Options({"foo": "bar", "baz": "qux"}) - opt2 = Options({"baz": "quz"}) + setattr(options, "baz", "qux") + assert set(options.keys()) == {"abstract", "foo", "baz"} - merged1 = opt1 + opt2 - assert merged1.baz == "quz" + options.waldo = "fred" + assert set(options.keys()) == {"abstract", "foo", "baz", "waldo"} - merged2 = opt2 + opt1 - assert merged2.baz == "qux" + del options.baz + assert set(options.keys()) == {"abstract", "foo", "waldo"} diff --git a/tests/reflection/test_fields.py b/tests/reflection/test_fields.py index 48415c62..8d2a7f5c 100644 --- a/tests/reflection/test_fields.py +++ b/tests/reflection/test_fields.py @@ -21,7 +21,7 @@ class Dummy: pass with pytest.raises(IncorrectUsageError) as exception: - fields(Dummy) + fields(Dummy) # type: ignore - This is expected to raise an exception. assert exception.value.args[0] == ( ".Dummy'> " diff --git a/tests/serializer/__init__.py b/tests/serializer/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/serializer/elements.py b/tests/serializer/elements.py deleted file mode 100644 index d1156322..00000000 --- a/tests/serializer/elements.py +++ /dev/null @@ -1,16 +0,0 @@ -from protean.core.aggregate import BaseAggregate -from protean.core.serializer import BaseSerializer -from protean.fields import Integer, String - - -class User(BaseAggregate): - name = String(required=True) - age = Integer(required=True) - - -class UserSchema(BaseSerializer): - name = String(required=True) - age = Integer(required=True) - - class Meta: - part_of = User diff --git a/tests/serializer/test_list_field.py b/tests/serializer/test_list_field.py deleted file mode 100644 index f3a5d54b..00000000 --- a/tests/serializer/test_list_field.py +++ /dev/null @@ -1,41 +0,0 @@ -from protean.core.aggregate import BaseAggregate -from protean.core.serializer import BaseSerializer -from protean.fields import Dict, Integer, List - - -class Foo(BaseAggregate): - bar = List(content_type=Integer) - - -class Qux(BaseAggregate): - bars = List(content_type=Dict, default=list) - - -class FooRepresentation(BaseSerializer): - bars = List(content_type=Dict) - - -def test_that_list_of_dicts_are_serialized_correctly(): - serialized = FooRepresentation().dump(Qux(bars=[{"a": 1, "b": 1}])) - assert serialized["bars"] == [{"a": 1, "b": 1}] - - -def test_that_list_fields_are_not_shared(): - foo1 = Foo(bar=[1, 2]) - foo2 = Foo(bar=[3, 4]) - - assert foo1.bar == [1, 2] - assert foo2.bar == [3, 4] - - qux1 = Qux(bars=[{"a": 1}, {"b": 2}]) - qux2 = Qux(bars=[{"c": 3}, {"d": 4}]) - - assert qux1.bars == [{"a": 1}, {"b": 2}] - assert qux2.bars == [{"c": 3}, {"d": 4}] - - qux3 = Qux() - qux3.bars.append({"a": 1}) - qux3.bars.append({"b": 2}) - - qux4 = Qux() - assert qux4.bars == [] diff --git a/tests/serializer/tests.py b/tests/serializer/tests.py deleted file mode 100644 index ce83a9e7..00000000 --- a/tests/serializer/tests.py +++ /dev/null @@ -1,48 +0,0 @@ -import pytest - -from protean.core.serializer import BaseSerializer -from protean.exceptions import NotSupportedError -from protean.fields import Integer, String -from protean.utils import fully_qualified_name -from protean.utils.reflection import declared_fields - -from .elements import User, UserSchema - - -class TestSerializerInitialization: - def test_that_base_serializer_class_cannot_be_instantiated(self): - with pytest.raises(NotSupportedError): - BaseSerializer() - - def test_that_a_concrete_serializer_can_be_instantiated(self): - schema = UserSchema() - assert schema is not None - - def test_that_meta_is_loaded_with_attributes(self): - assert UserSchema.meta_.part_of is not None - assert UserSchema.meta_.part_of == User - - assert declared_fields(UserSchema) is not None - assert all(key in declared_fields(UserSchema) for key in ["name", "age"]) - - -class TestSerializerRegistration: - def test_that_serializer_can_be_registered_with_domain(self, test_domain): - test_domain.register(UserSchema) - - assert fully_qualified_name(UserSchema) in test_domain.registry.serializers - - def test_that_serializer_can_be_registered_via_annotations(self, test_domain): - @test_domain.serializer - class PersonSchema: - name = String(required=True) - age = Integer(required=True) - - assert fully_qualified_name(PersonSchema) in test_domain.registry.serializers - - -class TestSerializerDump: - def test_that_serializer_dumps_data_from_domain_element(self): - user = User(name="John Doe", age=24) - json_result = UserSchema().dump(user) - assert json_result == {"age": 24, "name": "John Doe"} diff --git a/tests/server/test_engine_handle_exception.py b/tests/server/test_engine_handle_exception.py index 050865bc..0a5c1ee1 100644 --- a/tests/server/test_engine_handle_exception.py +++ b/tests/server/test_engine_handle_exception.py @@ -1,4 +1,3 @@ -import asyncio from unittest import mock import pytest diff --git a/tests/test_options.py b/tests/test_options.py deleted file mode 100644 index 82b7a6cc..00000000 --- a/tests/test_options.py +++ /dev/null @@ -1,94 +0,0 @@ -import pytest - -from protean.utils.container import Options - - -@pytest.fixture -def opts_dict(): - return {"opt1": "value1", "opt2": "value2", "abstract": True} - - -@pytest.fixture -def opts_object(): - return Options({"opt1": "value1", "opt2": "value2", "abstract": True}) - - -def test_init_with_dict(opts_dict): - options = Options(opts_dict) - assert options.opt1 == "value1" - assert options.opt2 == "value2" - assert options.abstract is True - - -def test_init_with_class(opts_object): - options = Options(opts_object) - assert options.opt1 == "value1" - assert options.opt2 == "value2" - assert options.abstract is True - - -def test_init_with_none(): - options = Options() - assert options.abstract is False - - -def test_init_with_invalid_type(): - with pytest.raises(ValueError): - Options("invalid") - - -def test_setattr_and_getattr(opts_dict): - options = Options(opts_dict) - options.opt3 = "value3" - assert options.opt3 == "value3" - assert "opt3" in options._opts - - -def test_delattr(opts_dict): - options = Options(opts_dict) - del options.opt1 - assert not hasattr(options, "opt1") - assert "opt1" not in options._opts - - -def test_eq(opts_dict): - options1 = Options(opts_dict) - options2 = Options(opts_dict) - assert options1 == options2 - - -def test_ne(opts_dict): - options1 = Options(opts_dict) - options2 = Options({"opt1": "value1", "opt2": "different_value", "abstract": True}) - assert options1 != options2 - - -def test_ne_different_type(opts_dict): - class NotOptions(Options): - pass - - options = Options(opts_dict) - not_options = NotOptions(opts_dict) - assert options != not_options - - -def test_hash(opts_dict): - options = Options(opts_dict) - assert isinstance(hash(options), int) - - -def test_add(opts_dict): - options1 = Options(opts_dict) - options2 = Options({"opt3": "value3"}) - options3 = options1 + options2 - assert options3.opt1 == "value1" - assert options3.opt2 == "value2" - assert options3.opt3 == "value3" - assert options3.abstract is False - - -def test_add_does_not_modify_original(opts_dict): - options1 = Options(opts_dict) - options2 = Options({"opt3": "value3"}) - options1 + options2 - assert not hasattr(options1, "opt3") diff --git a/tests/test_registry.py b/tests/test_registry.py index 75985512..b1a909a8 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -101,7 +101,6 @@ def test_properties_method_returns_a_dictionary_of_all_protean_elements(): "events": DomainObjects.EVENT.value, "models": DomainObjects.MODEL.value, "repositories": DomainObjects.REPOSITORY.value, - "serializers": DomainObjects.SERIALIZER.value, "subscribers": DomainObjects.SUBSCRIBER.value, "value_objects": DomainObjects.VALUE_OBJECT.value, "views": DomainObjects.VIEW.value,