From 7ab79a187b601b0a7d2ca2000246924d44478aef Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Thu, 25 Jul 2024 11:37:31 -0700 Subject: [PATCH] Support config for multiple environments (#448) Support config for multiple environments This commit supports specifying configurations for multiple environments in the `toml` configuration file. The active environment is specified in the `PROTEAN_ENV` environment variable. --- docs/guides/configuration.md | 235 ++++++++++++++++-- .../domain-definition/fields/simple-fields.md | 2 +- src/protean/adapters/email/__init__.py | 2 +- src/protean/container.py | 55 ++-- src/protean/domain/__init__.py | 53 +--- src/protean/domain/config.py | 79 +++++- src/protean/port/event_store.py | 4 +- .../adapters/email/sendgrid_email/domain.toml | 2 +- tests/config/domain.toml | 48 +++- tests/config/test_config_attribute.py | 43 ++++ tests/config/test_config_normalization.py | 66 +++++ tests/config/test_load.py | 15 +- tests/event/tests.py | 28 ++- tests/event_store/test_snapshotting.py | 6 +- tests/test_containers.py | 28 ++- tests/test_options.py | 94 +++++++ 16 files changed, 638 insertions(+), 122 deletions(-) create mode 100644 tests/config/test_config_attribute.py create mode 100644 tests/config/test_config_normalization.py create mode 100644 tests/test_options.py diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 5a2f9010..64c7b052 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -1,44 +1,157 @@ # Configuration -## Primary Configuration Attributes +Protean's configuration is specified in a `toml` file. A `domain.toml` file is +generated when you initialize a protean application with the +[`new`](./cli/new.md) command. -### `environment` +The configuration can be placed in a file named `.domain.toml` or `domain.toml`, +or can even leverage existing `pyproject.toml`. Protean looks for these files +- in that order - when the domain object is initialized. -The `environment` attribute specifies the current running environment of the -application. By default, the environment is `development`. The current -environment can be gathered from an environment variable called `PROTEAN_ENV`. +A sample configuration file is below: -Protean recognizes `development` and `production` environments, but additional -environments such as `pre-prod`, `staging`, and `testing` can be specified as -needed. +```toml +debug = true +testing = true +secret_key = "tvTpk3PAfkGr5x9!2sFU%XpW7bR8cwKA" +identity_strategy = "uuid" +identity_type = "string" +event_processing = "sync" +command_processing = "sync" + +[databases.default] +provider = "memory" + +[databases.memory] +provider = "memory" + +[brokers.default] +provider = "inline" + +[caches.default] +provider = "memory" + +[event_store] +provider = "memory" + +[custom] +foo = "bar" + +[staging] +event_processing = "async" +command_processing = "sync" + +[staging.databases.default] +provider = "sqlite" +database_url = "sqlite:///test.db" + +[staging.brokers.default] +provider = "redis" +URI = "redis://staging.example.com:6379/2" +TTL = 300 + +[staging.custom] +foo = "qux" + +[prod] +event_processing = "async" +command_processing = "async" + +[prod.databases.default] +provider = "postgresql" +database_url = "postgresql://postgres:postgres@localhost:5432/postgres" -- **Default Value**: `development` -- **Environment Variable**: `PROTEAN_ENV` -- **Examples**: - - `development` - - `production` - - `pre-prod` - - `staging` - - `testing` +[prod.brokers.default] +provider = "redis" +URI = "redis://prod.example.com:6379/2" +TTL = 30 + +[prod.event_store] +provider = "message_db" +database_uri = "postgresql://message_store@localhost:5433/message_store" + +[prod.custom] +foo = "quux" +``` + +## Basic Configuration Parameters ### `debug` +Specifies if the application is running in debug mode. + +***Do not enable debug mode when deploying in production.*** + +Default: `False` + ### `testing` +Enable testing mode. Exceptions are propagated rather than handled by the +domain’s error handlers. Extensions may also change their behavior to +facilitate easier testing. You should enable this in your own tests. + +Default: `False` + ### `secret_key` -## Domain Configuration Attributes +A secret key that will be used for security related needs by extensions or your +application. It should be a long random `bytes` or `str`. + +You can generate a secret key with the following command: + +```shell +> python -c 'import secrets; print(secrets.token_hex())' +c4bf0121035265bf44657217c33a7d041fe9e505961fc7da5d976aa0eaf5cf94 +``` + +***Do not reveal the secret key when posting questions or committing code.*** ### `identity_strategy` +The default strategy to use to generate an identity value. Can be overridden +at the [`Auto`](./domain-definition/fields/simple-fields.md#auto) field level. + +Supported options are `uuid` and `function`. + +If the `identity_strategy` is chosen to be a `function`, `identity_function` +has to be mandatorily specified during domain object initialization. + +Default: `uuid` + ### `identity_type` +The type of the identity value. Can be overridden +at the [`Auto`](./domain-definition/fields/simple-fields.md#auto) field level. + +Supported options are `integer`, `string`, and `uuid`. + +Default: `string` + ### `command_processing` +Whether to process commands synchronously or asynchronously. + +Supported options are `sync` and `async`. + +Default: `sync` + ### `event_processing` +Whether to process events synchronously or asynchronously. + +Supported options are `sync` and `async`. + +Default: `sync` + ### `snapshot_threshold` +The threshold number of aggregate events after which a snapshot is created to +optimize performance. + +Applies only when aggregates are event sourced. + +Default: `10` + ## Adapter Configuration ### `databases` @@ -65,8 +178,7 @@ by the `default` key, and is used when you do not specify a database name when accessing the domain. The only other database defined by default is `memory`, which is the in-memory -stub database provider. You can name all other database definitions as -necessary. +stub database provider. The persistence store defined here is then specified in the `provider` key of aggregates and entities to assign them a specific database. @@ -81,18 +193,67 @@ class User: 1. `sqlite` is the key of the database definition in the `[databases.sqlite]` section. -### `cache` +Read more in [Adapters → Database](../adapters/database/index.md) section. + +### `caches` + +This section holds definitions for cache infrastructure. + +```toml +[caches.default] +provider = "memory" + +[caches.redis] +provider = "redis" +URI = "redis://127.0.0.1:6379/2" +TTL = 300 +``` + +Default provider: `memory` + +Read more in [Adapters → Cache](../adapters/cache/index.md) section. ### `broker` +This section holds configurations for message brokers. + +```toml +[brokers.default] +provider = "memory" + +[brokers.redis] +provider = "redis" +URI = "redis://127.0.0.1:6379/0" +IS_ASYNC = true +``` + +Default provider: `memory` + +Read more in [Adapters → Broker](../adapters/broker/index.md) section. + ### `event_store` +The event store that stores event and command messages is defined in this +section. + +```toml +[event_store] +provider = "message_db" +database_uri = "postgresql://message_store@localhost:5433/message_store" +``` + +Note that there can only be only event store defined per domain. + +Default provider: `memory` + +Read more in [Adapters → Event Store](../adapters/eventstore/index.md) section. + ## Custom Attributes Custom attributes can be defined in toml under the `[custom]` section (or `[tool.protean.custom]` if you are leveraging the `pyproject.toml` file). -Custom attributes are also made available on the domain object directly. +Custom attributes are also made available as domain attributes. ```toml hl_lines="5" debug = true @@ -111,3 +272,35 @@ Out[2]: 'bar' In [3]: domain.FOO Out[3]: 'bar' ``` + +## Multiple Environments + +Most applications need more than one configuration. At the very least, there +should be separate configurations for production and for local development. +The `toml` configuration file can hold configurations for different +environments. + +The current environment is gathered from an environment variable named +`PROTEAN_ENV`. + +The string specified in `PROTEAN_ENV` is used as a qualifier in the +configuration. + +```toml hl_lines="4 8" +[databases.default] +provider = "memory" + +[staging.databases.default] +provider = "sqlite" +database_url = "sqlite:///test.db" + +[prod.databases.default] +provider = "postgresql" +database_url = "postgresql://postgres:postgres@localhost:5432/postgres" +``` + +Protean has a default configuration with memory stubs that is overridden +by configurations in the `toml` file, which can further be over-ridden by an +environment-specific configuration, as seen above. There are two environment +specific settings above for databases - an `sqlite` db configuration for +`staging` and a `postgresql` db configuration for `prod`. diff --git a/docs/guides/domain-definition/fields/simple-fields.md b/docs/guides/domain-definition/fields/simple-fields.md index e740b972..14834570 100644 --- a/docs/guides/domain-definition/fields/simple-fields.md +++ b/docs/guides/domain-definition/fields/simple-fields.md @@ -149,7 +149,7 @@ value is expected to be generated by the database at the time of persistence. Cross-check with the specific adapter's documentation and your database to confirm if the database supports this functionality. -- **`identity_strategy`**: The strategy to use to generate the identity value. +- **`identity_strategy`**: The strategy to use to generate an identity value. If not provided, the strategy defined at the domain level is used. - **`identity_function`**: A function that is used to generate the identity diff --git a/src/protean/adapters/email/__init__.py b/src/protean/adapters/email/__init__.py index 09d64967..3dec59ab 100644 --- a/src/protean/adapters/email/__init__.py +++ b/src/protean/adapters/email/__init__.py @@ -13,7 +13,7 @@ def __init__(self, domain): def _initialize_email_providers(self): """Read config file and initialize email providers""" - configured_email_providers = self.domain.config["EMAIL_PROVIDERS"] + configured_email_providers = self.domain.config["email_providers"] email_provider_objects = {} if configured_email_providers and isinstance(configured_email_providers, dict): diff --git a/src/protean/container.py b/src/protean/container.py index 5954a2c2..a74f526b 100644 --- a/src/protean/container.py +++ b/src/protean/container.py @@ -4,7 +4,7 @@ import inspect import logging from collections import defaultdict -from typing import Any, Type, Union +from typing import Any, Union from protean.exceptions import ( InvalidDataError, @@ -37,7 +37,7 @@ class Options: - ``abstract``: Indicates that this is an abstract entity (Ignores all other meta options) """ - def __init__(self, opts: Union[dict, Type] = None) -> None: + def __init__(self, opts: Union[dict, "Options"] = None) -> None: self._opts = set() if opts: @@ -59,6 +59,10 @@ def __init__(self, opts: Union[dict, Type] = None) -> None: 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." + ) else: # Common Meta attributes self.abstract = getattr(opts, "abstract", None) or False @@ -84,7 +88,8 @@ def __eq__(self, other) -> bool: def __hash__(self) -> int: """Overrides the default implementation and bases hashing on values""" - return hash(frozenset(self.__dict__.items())) + 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) @@ -208,32 +213,36 @@ def __init__(self, *template, **kwargs): # noqa: C901 self.errors = defaultdict(list) - # Load the attributes based on the template loaded_fields = [] + + # Gather values from template + template_values = {} for dictionary in template: if not isinstance(dictionary, dict): raise AssertionError( - f'Positional argument "{dictionary}" passed must be a dict.' + f"Positional argument '{dictionary}' passed must be a dict. " f"This argument serves as a template for loading common " f"values.", ) for field_name, val in dictionary.items(): - loaded_fields.append(field_name) - setattr(self, field_name, val) + template_values[field_name] = val + + supplied_values = {**template_values, **kwargs} # Now load against the keyword arguments - for field_name, val in kwargs.items(): + for field_name, val in supplied_values.items(): # Record that a field was encountered by appending to `loaded_fields` # When it fails validations, we want it's errors to be recorded # # Not remembering the field was recorded will result in it being set to `None` # which will raise a ValidationError of its own for the wrong reasons (required field not set) - loaded_fields.append(field_name) try: setattr(self, field_name, val) except ValidationError as err: for field_name in err.messages: self.errors[field_name].extend(err.messages[field_name]) + finally: + loaded_fields.append(field_name) # Load Value Objects from associated fields # This block will dynamically construct value objects from field values @@ -245,18 +254,26 @@ def __init__(self, *template, **kwargs): # noqa: C901 (embedded_field.field_name, embedded_field.attribute_name) for embedded_field in field_obj.embedded_fields.values() ] - values = {name: kwargs.get(attr) for name, attr in attrs} - try: - value_object = field_obj.value_object_cls(**values) - # Set VO value only if the value object is not None/Empty - if value_object: + kwargs_values = { + name: supplied_values.get(attr) for name, attr in attrs + } + + # Check if any of the values in `values` are not None + # If all values are None, it means that the value object is not being set + # and we should set it to None + # + # If any of the values are not None, we should set the value object and its attributes + # to the values provided and let it trigger validations + if any(kwargs_values.values()): + try: + value_object = field_obj.value_object_cls(**kwargs_values) setattr(self, field_name, value_object) loaded_fields.append(field_name) - except ValidationError as err: - for sub_field_name in err.messages: - self.errors["{}_{}".format(field_name, sub_field_name)].extend( - err.messages[sub_field_name] - ) + except ValidationError as err: + for sub_field_name in err.messages: + self.errors[ + "{}_{}".format(field_name, sub_field_name) + ].extend(err.messages[sub_field_name]) # Load Identities for field_name, field_obj in declared_fields(self).items(): diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index a0c2545f..9d4d57c8 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -42,7 +42,6 @@ from .config import Config2, ConfigAttribute from .context import DomainContext, _DomainContextGlobals -from .helpers import get_debug_flag, get_env logger = logging.getLogger(__name__) @@ -50,48 +49,6 @@ _sentinel = object() -def _default_config(): - """Return the default configuration for a Protean application. - - This is placed in a separate function because we want to be absolutely - sure that we are using a copy of the defaults when we manipulate config - directly in tests. Housing it within the main `Domain` class can - potentially lead to issues because the config can be overwritten by accident. - """ - from protean.utils import IdentityStrategy, IdentityType - - return { - "env": None, - "debug": None, - "secret_key": None, - "identity_strategy": IdentityStrategy.UUID.value, - "identity_type": IdentityType.STRING.value, - "databases": { - "default": {"provider": "memory"}, - "memory": {"provider": "memory"}, - }, - "event_processing": EventProcessing.ASYNC.value, - "command_processing": CommandProcessing.ASYNC.value, - "event_store": { - "provider": "memory", - }, - "caches": { - "default": { - "provider": "memory", - "TTL": 300, - } - }, - "brokers": {"default": {"provider": "inline"}}, - "EMAIL_PROVIDERS": { - "default": { - "provider": "protean.adapters.DummyEmailProvider", - "DEFAULT_FROM_EMAIL": "admin@team8solutions.com", - }, - }, - "SNAPSHOT_THRESHOLD": 10, - } - - class Domain: """The domain object is a one-stop gateway to: @@ -121,7 +78,7 @@ class Domain: #: #: **Do not enable development when deploying in production.** #: - #: Default: ``'production'`` + #: Default: ``'development'`` env = ConfigAttribute("env") #: The testing flag. Set this to ``True`` to enable the test mode of @@ -310,6 +267,7 @@ def _traverse(self): directories_to_traverse = [str(root_dir)] # Include root directory # Identify subdirectories that have a toml file + # And ignore them from traversal files_to_check = ["domain.toml", ".domain.toml", "pyproject.toml"] for subdirectory in subdirectories: subdirectory_path = os.path.join(root_dir, subdirectory) @@ -360,13 +318,10 @@ def _initialize(self): def load_config(self, load_toml=True): """Load configuration from dist or a .toml file.""" - defaults = _default_config() - defaults["env"] = get_env() - defaults["debug"] = get_debug_flag() if load_toml: - config = Config2.load_from_path(self.root_path, defaults) + config = Config2.load_from_path(self.root_path) else: - config = Config2.load_from_dict(defaults) + config = Config2.load_from_dict() # Load Constants if "custom" in config: diff --git a/src/protean/domain/config.py b/src/protean/domain/config.py index 60c3377e..b8f31209 100644 --- a/src/protean/domain/config.py +++ b/src/protean/domain/config.py @@ -5,10 +5,55 @@ import tomllib from protean.exceptions import ConfigurationError +from protean.utils import CommandProcessing, EventProcessing logger = logging.getLogger(__name__) +def _default_config(): + """Return the default configuration for a Protean application. + + This is placed in a separate function because we want to be absolutely + sure that we are using a copy of the defaults when we manipulate config + directly in tests. Housing it within the main `Domain` class can + potentially lead to issues because the config can be overwritten by accident. + """ + from protean.utils import IdentityStrategy, IdentityType + + return { + "env": None, + "testing": None, + "debug": None, + "secret_key": None, + "identity_strategy": IdentityStrategy.UUID.value, + "identity_type": IdentityType.STRING.value, + "databases": { + "default": {"provider": "memory"}, + "memory": {"provider": "memory"}, + }, + "event_processing": EventProcessing.ASYNC.value, + "command_processing": CommandProcessing.ASYNC.value, + "event_store": { + "provider": "memory", + }, + "caches": { + "default": { + "provider": "memory", + "TTL": 300, + } + }, + "brokers": {"default": {"provider": "inline"}}, + "email_providers": { + "default": { + "provider": "protean.adapters.DummyEmailProvider", + "DEFAULT_FROM_EMAIL": "admin@team8solutions.com", + }, + }, + "snapshot_threshold": 10, + "custom": {}, + } + + class ConfigAttribute: """Makes an attribute forward to the config""" @@ -26,12 +71,12 @@ class Config2(dict): ENV_VAR_PATTERN = re.compile(r"\$\{([^}]+)\}") @classmethod - def load_from_dict(cls, config: dict): + def load_from_dict(cls, config: dict = _default_config()): """Load configuration from a dictionary.""" - return cls(**config) + return cls(**cls._normalize_config(config)) @classmethod - def load_from_path(cls, path: str, defaults: dict = None): + def load_from_path(cls, path: str): def find_config_file(directory: str): config_files = [".domain.toml", "domain.toml", "pyproject.toml"] for config_file in config_files: @@ -66,14 +111,38 @@ def find_config_file(directory: str): if config_file_name.endswith("pyproject.toml"): config = config.get("tool", {}).get("protean", {}) - # Merge with defaults - config = cls._deep_merge(defaults, config) + config = cls._normalize_config(config) # Load environment variables config = cls._load_env_vars(config) return cls(**config) + @classmethod + def _normalize_config(cls, config): + """Normalize configuration values. + + This method accepts a dictionary and combines the values from the + configured environment to create a finalized configuration dictionary. + """ + # Extract the value of PROTEAN_ENV environment variable + environment = os.environ.get("PROTEAN_ENV") or None + + # Gather values of known variables + keys = _default_config().keys() + finalized_config = {key: value for key, value in config.items() if key in keys} + + # Merge with defaults + finalized_config = cls._deep_merge(_default_config(), finalized_config) + + # Look for section linked to the specified environment + if environment and environment in config: + environment_config = config[environment] + # Merge the environment section with the base configuration + finalized_config = cls._deep_merge(finalized_config, environment_config) + + return finalized_config + @classmethod def _deep_merge(cls, dict1: dict, dict2: dict): result = dict1.copy() diff --git a/src/protean/port/event_store.py b/src/protean/port/event_store.py index 2d1e0b1c..a7b459a9 100644 --- a/src/protean/port/event_store.py +++ b/src/protean/port/event_store.py @@ -169,11 +169,11 @@ def load_aggregate( and len(event_stream) > 1 and ( event_stream[-1]["position"] - position_in_snapshot - >= self.domain.config["SNAPSHOT_THRESHOLD"] + >= self.domain.config["snapshot_threshold"] ) ) or ( not snapshot_message - and len(event_stream) >= self.domain.config["SNAPSHOT_THRESHOLD"] + and len(event_stream) >= self.domain.config["snapshot_threshold"] ): # Snapshot is of type "SNAPSHOT" and contains only the aggregate's data # (no metadata, so no event type) diff --git a/tests/adapters/email/sendgrid_email/domain.toml b/tests/adapters/email/sendgrid_email/domain.toml index aae4955f..80290451 100644 --- a/tests/adapters/email/sendgrid_email/domain.toml +++ b/tests/adapters/email/sendgrid_email/domain.toml @@ -1,4 +1,4 @@ -[EMAIL_PROVIDERS.default] +[email_providers.default] provider = "protean.SendgridEmailProvider" DEFAULT_FROM_EMAIL = "admin@team8solutions.com" API_KEY = "this-is-a-fake-key" \ No newline at end of file diff --git a/tests/config/domain.toml b/tests/config/domain.toml index ec105bb1..fa639f5e 100644 --- a/tests/config/domain.toml +++ b/tests/config/domain.toml @@ -3,6 +3,8 @@ testing = true secret_key = "tvTpk3PAfkGr5x9!2sFU%XpW7bR8cwKA" identity_strategy = "uuid" identity_type = "string" +event_processing = "sync" +command_processing = "sync" [databases.default] provider = "memory" @@ -10,12 +12,50 @@ provider = "memory" [databases.memory] provider = "memory" -[databases.sqlite] -provider = "sqlite" -database_uri = "sqlite:///test.db" - [brokers.default] provider = "inline" [caches.default] provider = "memory" + +[event_store] +provider = "memory" + +[custom] +foo = "bar" + +[staging] +event_processing = "async" +command_processing = "sync" + +[staging.databases.default] +provider = "sqlite" +database_url = "sqlite:///test.db" + +[staging.brokers.default] +provider = "redis" +URI = "redis://staging.example.com:6379/2" +TTL = 300 + +[staging.custom] +foo = "qux" + +[prod] +event_processing = "async" +command_processing = "async" + +[prod.databases.default] +provider = "postgresql" +database_url = "postgresql://postgres:postgres@localhost:5432/postgres" + +[prod.brokers.default] +provider = "redis" +URI = "redis://prod.example.com:6379/2" +TTL = 30 + +[prod.event_store] +provider = "message_db" +database_uri = "postgresql://message_store@localhost:5433/message_store" + +[prod.custom] +foo = "quux" \ No newline at end of file diff --git a/tests/config/test_config_attribute.py b/tests/config/test_config_attribute.py new file mode 100644 index 00000000..8108b0ac --- /dev/null +++ b/tests/config/test_config_attribute.py @@ -0,0 +1,43 @@ +import pytest + +from protean.domain.config import ConfigAttribute + + +class MockObject: + def __init__(self): + self.config = {} + + +class TestConfigAttribute: + @pytest.fixture + def mock_obj(self): + return MockObject() + + @pytest.fixture + def config_attr(self): + return ConfigAttribute("test_attribute") + + def test_initialization(self, config_attr): + assert config_attr.__name__ == "test_attribute" + + def test_get(self, mock_obj, config_attr): + mock_obj.config["test_attribute"] = "value" + assert config_attr.__get__(mock_obj) == "value" + + def test_set(self, mock_obj, config_attr): + config_attr.__set__(mock_obj, "new_value") + assert mock_obj.config["test_attribute"] == "new_value" + + def test_descriptor_access(self, mock_obj): + class Example: + test_attribute = ConfigAttribute("test_attribute") + + example = Example() + example.config = {"test_attribute": "initial_value"} + + # Test getting the attribute + assert example.test_attribute == "initial_value" + + # Test setting the attribute + example.test_attribute = "updated_value" + assert example.config["test_attribute"] == "updated_value" diff --git a/tests/config/test_config_normalization.py b/tests/config/test_config_normalization.py new file mode 100644 index 00000000..0a5f442f --- /dev/null +++ b/tests/config/test_config_normalization.py @@ -0,0 +1,66 @@ +import pytest +import tomllib + +from protean.domain.config import Config2 + + +@pytest.fixture +def config(): + # Load config from a TOML file present in the same folder as this test file + with open("tests/config/domain.toml", "rb") as f: + config = tomllib.load(f) + + yield config + + +def test_normalized_config_without_environment(config): + assert config["debug"] is True + assert config["secret_key"] == "tvTpk3PAfkGr5x9!2sFU%XpW7bR8cwKA" + assert config["identity_strategy"] == "uuid" + assert config["identity_type"] == "string" + assert config["event_processing"] == "sync" + assert config["command_processing"] == "sync" + assert config["databases"]["default"]["provider"] == "memory" + assert config["databases"]["memory"]["provider"] == "memory" + assert config["brokers"]["default"]["provider"] == "inline" + assert config["caches"]["default"]["provider"] == "memory" + assert config["event_store"]["provider"] == "memory" + assert config["custom"]["foo"] == "bar" + + +def test_normalized_config_with_staging_environment(config, monkeypatch): + monkeypatch.setenv("PROTEAN_ENV", "staging") + + config = Config2._normalize_config(config) + + assert config["debug"] is True + assert config["secret_key"] == "tvTpk3PAfkGr5x9!2sFU%XpW7bR8cwKA" + assert config["identity_strategy"] == "uuid" + assert config["identity_type"] == "string" + assert config["event_processing"] == "async" + assert config["command_processing"] == "sync" + assert config["databases"]["default"]["provider"] == "sqlite" + assert config["databases"]["memory"]["provider"] == "memory" + assert config["brokers"]["default"]["provider"] == "redis" + assert config["caches"]["default"]["provider"] == "memory" + assert config["event_store"]["provider"] == "memory" + assert config["custom"]["foo"] == "qux" + + +def test_normalized_config_with_prod_environment(config, monkeypatch): + monkeypatch.setenv("PROTEAN_ENV", "prod") + + config = Config2._normalize_config(config) + + assert config["debug"] is True + assert config["secret_key"] == "tvTpk3PAfkGr5x9!2sFU%XpW7bR8cwKA" + assert config["identity_strategy"] == "uuid" + assert config["identity_type"] == "string" + assert config["event_processing"] == "async" + assert config["command_processing"] == "async" + assert config["databases"]["default"]["provider"] == "postgresql" + assert config["databases"]["memory"]["provider"] == "memory" + assert config["brokers"]["default"]["provider"] == "redis" + assert config["caches"]["default"]["provider"] == "memory" + assert config["event_store"]["provider"] == "message_db" + assert config["custom"]["foo"] == "quux" diff --git a/tests/config/test_load.py b/tests/config/test_load.py index b2281627..671ba678 100644 --- a/tests/config/test_load.py +++ b/tests/config/test_load.py @@ -34,19 +34,6 @@ def test_loading_domain_config(self, test_domain): for key in ["databases", "caches", "brokers", "event_store"] ) - def test_domain_config_defaults(self): - change_working_directory_to("test14") - - defaults = { - "custom": { - "qux": "quux", - } - } - - config = Config2.load_from_path("test14", defaults) - assert config["custom"]["FOO"] == "bar" - assert config["custom"]["qux"] == "quux" - @pytest.mark.no_test_domain def test_domain_detects_config_file(self): change_working_directory_to("test14") @@ -125,7 +112,7 @@ def test_domain_throws_error_if_config_file_not_found(self): class TestLoadingDefaults: def test_that_config_is_loaded_from_dict(self): - from protean.domain import _default_config + from protean.domain.config import _default_config config_dict = _default_config() config_dict["custom"] = {"FOO": "bar", "qux": "quux"} diff --git a/tests/event/tests.py b/tests/event/tests.py index f642af36..d120c235 100644 --- a/tests/event/tests.py +++ b/tests/event/tests.py @@ -3,7 +3,7 @@ import pytest from protean import BaseAggregate, BaseEvent, BaseValueObject -from protean.exceptions import IncorrectUsageError, NotSupportedError +from protean.exceptions import IncorrectUsageError, NotSupportedError, ValidationError from protean.fields import Identifier, String, ValueObject from protean.reflection import data_fields, declared_fields, fields from protean.utils import fully_qualified_name @@ -76,6 +76,32 @@ class UserAdded(BaseEvent): } ) + def test_error_on_invalid_value_object(self, test_domain): + class Address(BaseValueObject): + street = String(max_length=50, required=True) + city = String(max_length=25, required=True) + + class Person(BaseAggregate): + name = String(max_length=50) + address = ValueObject(Address, required=True) + + class PersonAdded(BaseEvent): + id = Identifier(identifier=True) + name = String(max_length=50) + address = ValueObject(Address) + + test_domain.register(PersonAdded, part_of=Person) + test_domain.init(traverse=False) + + with pytest.raises(ValidationError) as exc: + PersonAdded( + id=uuid.uuid4(), + name="John Doe", + address={"street": "123 Main St"}, + ) + + assert exc.value.messages == {"city": ["is required"]} + def test_that_domain_event_can_be_reconstructed_from_dict_enclosing_vo( self, test_domain ): diff --git a/tests/event_store/test_snapshotting.py b/tests/event_store/test_snapshotting.py index 44fcdd40..0f68f3cb 100644 --- a/tests/event_store/test_snapshotting.py +++ b/tests/event_store/test_snapshotting.py @@ -100,7 +100,7 @@ def test_that_snapshot_is_constructed_after_threshold(test_domain): for i in range( 3, - test_domain.config["SNAPSHOT_THRESHOLD"] + test_domain.config["snapshot_threshold"] + 2, # Run one time more than threshold ): # Start at 3 because we already have two events with UnitOfWork(): @@ -130,7 +130,7 @@ def test_that_a_stream_can_have_multiple_snapshots_but_latest_is_considered( user.activate() repo.add(user) - for i in range(3, (2 * test_domain.config["SNAPSHOT_THRESHOLD"]) + 2): + for i in range(3, (2 * test_domain.config["snapshot_threshold"]) + 2): with UnitOfWork(): user = repo.get(identifier) user.change_name(f"John Doe {i}") @@ -158,7 +158,7 @@ def test_that_a_stream_with_a_snapshop_and_no_further_events_is_reconstructed_co user.activate() repo.add(user) - for i in range(3, (2 * test_domain.config["SNAPSHOT_THRESHOLD"]) + 2): + for i in range(3, (2 * test_domain.config["snapshot_threshold"]) + 2): with UnitOfWork(): user = repo.get(identifier) user.change_name(f"John Doe {i}") diff --git a/tests/test_containers.py b/tests/test_containers.py index 40eebb53..98fc423b 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -1,6 +1,7 @@ import pytest from protean.container import BaseContainer, OptionsMixin +from protean.core.view import BaseView from protean.exceptions import InvalidDataError, NotSupportedError from protean.fields import Integer, String from protean.reflection import declared_fields @@ -19,19 +20,44 @@ class CustomContainer(CustomContainerMeta, OptionsMixin): def test_that_base_container_class_cannot_be_instantiated(): - with pytest.raises(NotSupportedError): + with pytest.raises(NotSupportedError) as exc: BaseContainer() + assert str(exc.value) == "BaseContainer cannot be instantiated" + class TestContainerInitialization: def test_that_base_container_class_cannot_be_instantiated(self): with pytest.raises(TypeError): CustomContainerMeta() + def test_abstract_containers_cannot_be_instantiated(self, test_domain): + class AbstractView(BaseView): + foo = String() + + test_domain.register(AbstractView, abstract=True) + + with pytest.raises(NotSupportedError) as exc: + AbstractView(foo="bar") + + assert ( + str(exc.value) + == "AbstractView class has been marked abstract and cannot be instantiated" + ) + def test_that_a_concrete_custom_container_can_be_instantiated(self): custom = CustomContainer(foo="a", bar="b") assert custom is not None + def test_that_init_args_object_needs_to_be_a_dict(self): + with pytest.raises(AssertionError) as exc: + CustomContainer("a", "b") + + assert str(exc.value) == ( + "Positional argument 'a' passed must be a dict. " + "This argument serves as a template for loading common values." + ) + class TestContainerProperties: def test_two_containers_with_equal_values_are_considered_equal(self): diff --git a/tests/test_options.py b/tests/test_options.py new file mode 100644 index 00000000..286963bb --- /dev/null +++ b/tests/test_options.py @@ -0,0 +1,94 @@ +import pytest + +from protean.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")