From e4a56f4c8e96de110019d30a5233e58a24e68570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20G=C3=B3recki?= Date: Mon, 23 Oct 2023 13:20:50 +0200 Subject: [PATCH] emitting and handling events --- .editorconfig | 19 + .gitignore | 2 +- .pre-commit-config.yaml | 31 + Makefile | 3 + README.md | 69 +- docs/.gitkeep | 0 examples/example1/main.py | 0 lato/__init__.py | 5 + lato/application.py | 76 +++ lato/application_module.py | 64 ++ lato/dependency_provider.py | 192 ++++++ lato/transaction_context.py | 89 +++ lato/utils.py | 16 + poetry.lock | 600 ++++++++++++++++++ pyproject.toml | 29 + tests/__init__.py | 0 tests/modular_application/__init__.py | 51 ++ tests/modular_application/employee_module.py | 30 + .../project_module/__init__.py | 7 + .../project_module/events.py | 6 + .../project_module/use_cases.py | 6 + tests/test_application.py | 166 +++++ tests/test_application_example_from_readme.py | 38 ++ tests/test_dependency_provider.py | 101 +++ tests/test_modular_application.py | 48 ++ tests/test_transaction_context.py | 39 ++ 26 files changed, 1684 insertions(+), 3 deletions(-) create mode 100644 .editorconfig create mode 100644 .pre-commit-config.yaml create mode 100644 Makefile create mode 100644 docs/.gitkeep create mode 100644 examples/example1/main.py create mode 100644 lato/__init__.py create mode 100644 lato/application.py create mode 100644 lato/application_module.py create mode 100644 lato/dependency_provider.py create mode 100644 lato/transaction_context.py create mode 100644 lato/utils.py create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/modular_application/__init__.py create mode 100644 tests/modular_application/employee_module.py create mode 100644 tests/modular_application/project_module/__init__.py create mode 100644 tests/modular_application/project_module/events.py create mode 100644 tests/modular_application/project_module/use_cases.py create mode 100644 tests/test_application.py create mode 100644 tests/test_application_example_from_readme.py create mode 100644 tests/test_dependency_provider.py create mode 100644 tests/test_modular_application.py create mode 100644 tests/test_transaction_context.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d0e7f6b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false + +[*.yaml] +indent_size = 2 + +[{Makefile,**.mk}] +# Use tabs for indentation (Makefiles require tabs) +indent_style = tab diff --git a/.gitignore b/.gitignore index 68bc17f..2dc53ca 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..09d896a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,31 @@ +default_language_version: + python: python3.10 + +repos: + # native hints instead of `from typing` | List -> list + - repo: https://github.com/sondrelg/pep585-upgrade + rev: 'v1.0' # Version to check + hooks: + - id: upgrade-type-hints + + # Only for removing unused imports > Other staff done by Black + - repo: https://github.com/myint/autoflake + rev: "v1.4" # Version to check + hooks: + - id: autoflake + args: + - --in-place + - --remove-all-unused-imports + - --ignore-init-module-imports + + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + name: isort (python) + args: ["--profile", "black"] + + - repo: https://github.com/ambv/black + rev: 22.3.0 + hooks: + - id: black diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8a86f98 --- /dev/null +++ b/Makefile @@ -0,0 +1,3 @@ +publish: + python3 -m build + python3 -m twine upload --repository pypi dist/* \ No newline at end of file diff --git a/README.md b/README.md index eef8b15..6b6fc16 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,67 @@ -# lato -Python microframework for modular monoliths and loosely coupled application +# Lato + +## Overview +Lato is a Python microframework designed for building modular monoliths and loosely coupled applications. + +## Features + +- **Modularity**: Organize your application into smaller, independent modules for better maintainability. + +- **Flexibility**: Loosely couple your application components, making them easier to refactor and extend. + +- **Minimalistic**: Intuitive and lean API for rapid development without the bloat. + +- **Testability**: Easily test your application components in isolation. + +## Installation + +Install `lato` using pip: + +```bash +pip install lato +``` + +## Quickstart + +Here's a simple example to get you started: + +```python +from lato import Application, TransactionContext +from uuid import uuid4 + + +class UserService: + def create_user(self, email, password): + ... + + +class EmailService: + def send_welcome_email(self, email): + ... + + +app = Application( + name="Hello World", + # dependencies + user_service=UserService(), + email_service=EmailService(), +) + + +def create_user_use_case(email, password, session_id, ctx: TransactionContext, user_service: UserService): + # session_id, TransactionContext and UserService are automatically injected by `ctx.call` + print("Session ID:", session_id) + user_service.create_user(email, password) + ctx.emit("user_created", email) + + +@app.on("user_created") +def on_user_created(email, email_service: EmailService): + email_service.send_welcome_email(email) + + +with app.transaction_context(session_id=uuid4()) as ctx: + # session_id is transaction scoped dependency + result = ctx.call(create_user_use_case, "alice@example.com", "password") +``` + diff --git a/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/example1/main.py b/examples/example1/main.py new file mode 100644 index 0000000..e69de29 diff --git a/lato/__init__.py b/lato/__init__.py new file mode 100644 index 0000000..39d3dcb --- /dev/null +++ b/lato/__init__.py @@ -0,0 +1,5 @@ +from lato.application import Application +from lato.application_module import ApplicationModule +from lato.transaction_context import TransactionContext + +__all__ = [Application, ApplicationModule, TransactionContext] diff --git a/lato/application.py b/lato/application.py new file mode 100644 index 0000000..7e0ff76 --- /dev/null +++ b/lato/application.py @@ -0,0 +1,76 @@ +from typing import Any, Callable + +from lato.application_module import ApplicationModule +from lato.dependency_provider import DependencyProvider +from lato.transaction_context import TransactionContext + + +class Application(ApplicationModule): + dependency_provider_class = DependencyProvider + + def __init__(self, name=__name__, dependency_provider=None, **kwargs): + super().__init__(name) + self.dependency_provider = ( + dependency_provider or self.dependency_provider_class(**kwargs) + ) + self._on_enter_transaction_context = lambda ctx: None + self._on_exit_transaction_context = lambda ctx, exception=None: None + self._transaction_middlewares = [] + + def get_dependency(self, identifier: Any) -> Any: + """Get a dependency from the dependency provider""" + return self.dependency_provider.get_dependency(identifier) + + def __getitem__(self, item) -> Any: + return self.get_dependency(item) + + def call(self, func: Callable | str, *args, **kwargs): + with self.transaction_context() as ctx: + result = ctx.call(func, *args, **kwargs) + return result + + def on_enter_transaction_context(self, func): + """ + Decorator for registering a function to be called when entering a transaction context + + :param func: + :return: + """ + self._on_enter_transaction_context = func + return func + + def on_exit_transaction_context(self, func): + """ + Decorator for registering a function to be called when exiting a transaction context + + :param func: + :return: + """ + self._on_exit_transaction_context = func + return func + + def transaction_middleware(self, middleware_func): + """ + Decorator for registering a middleware function to be called when executing a function in a transaction context + :param middleware_func: + :return: + """ + self._transaction_middlewares.insert(0, middleware_func) + return middleware_func + + def transaction_context(self, **dependencies) -> TransactionContext: + """ + Creates a transaction context with the application dependencies + + :param dependencies: + :return: + """ + dp = self.dependency_provider.copy(**dependencies) + ctx = TransactionContext(dependency_provider=dp) + ctx.configure( + on_enter_transaction_context=self._on_enter_transaction_context, + on_exit_transaction_context=self._on_exit_transaction_context, + middlewares=self._transaction_middlewares, + handlers_iterator=self.iterate_handlers_for, + ) + return ctx diff --git a/lato/application_module.py b/lato/application_module.py new file mode 100644 index 0000000..f677741 --- /dev/null +++ b/lato/application_module.py @@ -0,0 +1,64 @@ +from collections import defaultdict + +from utils import OrderedSet + + +class ApplicationModule: + def __init__(self, name: str): + self.name: str = name + self._handlers: dict[str, set[callable]] = defaultdict(OrderedSet) + self._submodules: OrderedSet[ApplicationModule] = OrderedSet() + + def include_submodule(self, a_module): + assert isinstance( + a_module, ApplicationModule + ), f"Can only include {ApplicationModule} instances, got {a_module}" + self._submodules.add(a_module) + + def handler(self, alias): + """ + Decorator for registering use cases by name + """ + if callable(alias): + func = alias + alias = func.__name__ + assert len(self._handlers[alias]) == 0 + self._handlers[alias].add(func) + return func + + def decorator(func): + """ + Decorator for registering use cases by name + """ + assert len(self._handlers[alias]) == 0 + self._handlers[alias].add(func) + return func + + return decorator + + def iterate_handlers_for(self, alias: str): + if alias in self._handlers: + for handler in self._handlers[alias]: + yield handler + for submodule in self._submodules: + try: + yield from submodule.iterate_handlers_for(alias) + except KeyError: + pass + + def on(self, event_name): + # TODO: add matcher parameter + def decorator(func): + """ + Decorator for registering an event handler + + :param event_handler: + :return: + """ + self._handlers[event_name].add(func) + return func + + return decorator + + def __repr__(self): + return f"<{self.name} {object.__repr__(self)}>" diff --git a/lato/dependency_provider.py b/lato/dependency_provider.py new file mode 100644 index 0000000..fc310d2 --- /dev/null +++ b/lato/dependency_provider.py @@ -0,0 +1,192 @@ +import inspect +from typing import Any + +from utils import OrderedDict + + +class UnknownDependencyError(KeyError): + ... + + +def is_instance_of_custom_class(x): + """ + Check if x is an instance of a custom (user-defined) class. + + :param x: Object to check + :return: True if x is an instance of a custom class, otherwise False + """ + return hasattr(x, "__class__") + + +def get_function_parameters(func) -> OrderedDict: + """ + Retrieve the function's parameters and their annotations. + + :param func: The function to inspect + :return: An ordered dictionary of parameter names to their annotations + """ + handler_signature = inspect.signature(func) + kwargs_iterator = iter(handler_signature.parameters.items()) + parameters = OrderedDict() + for name, param in kwargs_iterator: + parameters[name] = param.annotation + return parameters + + +class DependencyProvider: + """ + A dependency provider that manages dependencies and helps in automatic + dependency injection based on type or parameter name. + """ + + def __init__(self, *args, **kwargs): + """ + Initialize the DependencyProvider. + + :param args: Class instances to be registered by types + :param kwargs: Dependencies to be registered by types and with explicit names + """ + self._dependencies = {} + self.update(*args, **kwargs) + + def update(self, *args, **kwargs): + """ + Update the dependency provider with new dependencies. + + :param args: Class instances to be updated by types + :param kwargs: Dependencies to be registered by types and with explicit names + """ + for value in args: + if is_instance_of_custom_class(value): + self.register_dependency(value.__class__, value) + else: + raise ValueError( + f"Got {type(value)}, expected a class. Only class instances can be passed as args. Use kwargs instead." + ) + + for name, value in kwargs.items(): + self.register_dependency(name, value) + if is_instance_of_custom_class(value): + self.register_dependency(value.__class__, value) + + def register_dependency(self, identifier: str | type, dependency: Any): + """ + Register a dependency with a given identifier (name or type). + + :param identifier: The name or type to be used as an identifier for the dependency + :param dependency: The actual dependency + """ + self._dependencies[identifier] = dependency + + def has_dependency(self, identifier: str | type) -> bool: + """ + Check if a dependency with the given identifier exists. + + :param identifier: Identifier for the dependency + :return: True if the dependency exists, otherwise False + """ + return identifier in self._dependencies + + def get_dependency(self, identifier: str | type) -> Any: + """ + Retrieve a dependency using its identifier (name or type). + + :param identifier: Identifier for the dependency + :return: The associated dependency + """ + try: + return self._dependencies[identifier] + except KeyError as e: + raise UnknownDependencyError(identifier) + + def _resolve_arguments( + self, function_parameters: OrderedDict, overrides: dict[str, Any] + ) -> dict[str, Any]: + """ + Resolve given function parameters to their corresponding dependencies. + + :param function_parameters: Parameters of the function + :param overrides: Manual overrides for dependencies + :return: A dictionary of resolved dependencies + """ + + def _resolve(identifier, overrides): + if identifier in overrides: + return overrides[identifier] + return self.get_dependency(identifier) + + kwargs = {} + for param_name, param_type in function_parameters.items(): + # first, try to resolve by type + if param_type == inspect.Parameter.empty: + try: + kwargs[param_name] = _resolve(param_type, overrides) + continue + except (ValueError, KeyError): + pass + # then, try to resolve by name + try: + kwargs[param_name] = _resolve(param_name, overrides) + continue + except (ValueError, KeyError): + pass + + return kwargs + + def resolve_func_params( + self, + func: callable, + func_args: list[Any] = None, + func_kwargs: dict[str, Any] = None, + ) -> dict[str, Any]: + """ + Resolve function parameters by providing necessary kwargs to call the function. + + :param func: The function to get arguments for + :param func_args: Positional arguments to the function + :param func_kwargs: Keyword arguments to the function + :return: A dictionary of keyword arguments + """ + + if func_args is None: + func_args = [] + if func_kwargs is None: + func_kwargs = {} + + func_parameters = get_function_parameters(func) + resolved_kwargs = OrderedDict() + arg_idx = 0 + for param_name, param_type in func_parameters.items(): + if arg_idx < len(func_args): + resolved_kwargs[param_name] = func_args[arg_idx] + arg_idx += 1 + continue + + if param_name in func_kwargs: + resolved_kwargs[param_name] = func_kwargs[param_name] + elif param_type != inspect.Parameter.empty and self.has_dependency( + param_type + ): + resolved_kwargs[param_name] = self.get_dependency(param_type) + elif self.has_dependency(param_name): + resolved_kwargs[param_name] = self.get_dependency(param_name) + + return resolved_kwargs + + def __getitem__(self, key): + return self.get_dependency(key) + + def __setitem__(self, key, value): + self.register_dependency(key, value) + + def copy(self, *args, **kwargs) -> "DependencyProvider": + """ + Create a copy of the dependency provider with updated dependencies. + :param args: typed overrides + :param kwargs: named overrides + :return: A copy of the dependency provider + """ + dp = DependencyProvider() + dp._dependencies.update(self._dependencies) + dp.update(*args, **kwargs) + return dp diff --git a/lato/transaction_context.py b/lato/transaction_context.py new file mode 100644 index 0000000..adcc5bd --- /dev/null +++ b/lato/transaction_context.py @@ -0,0 +1,89 @@ +from collections import OrderedDict +from functools import partial +from typing import Any + +from dependency_provider import DependencyProvider + + +class TransactionContext: + """A context spanning a single transaction for execution of a function""" + + dependency_provider_factory = DependencyProvider + + def __init__(self, dependency_provider: DependencyProvider = None, *args, **kwargs): + self.dependency_provider = ( + dependency_provider or self.dependency_provider_factory(*args, **kwargs) + ) + self._on_enter_transaction_context = lambda ctx: None + self._on_exit_transaction_context = lambda ctx, exception=None: None + self._middlewares = [] + self._handlers_iterator = lambda alias: iter([]) + + def configure( + self, + on_enter_transaction_context=None, + on_exit_transaction_context=None, + middlewares=None, + handlers_iterator=None, + ): + if on_enter_transaction_context: + self._on_enter_transaction_context = on_enter_transaction_context + if on_exit_transaction_context: + self._on_exit_transaction_context = on_exit_transaction_context + if middlewares: + self._middlewares = middlewares + if handlers_iterator: + self._handlers_iterator = handlers_iterator + + def begin(self): + """Should be used to start a transaction""" + self._on_enter_transaction_context(self) + + def end(self, exception=None): + """Should be used to commit/end a transaction""" + self._on_exit_transaction_context(self, exception) + + def iterate_handlers_for(self, alias: str): + yield from self._handlers_iterator(alias) + + def __enter__(self): + self.begin() + return self + + def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): + self.end(exc_val) + + def _wrap_with_middlewares(self, handler_func): + p = handler_func + for middleware in self._middlewares: + p = partial(middleware, self, p) + return p + + def call(self, func, *func_args, **func_kwargs) -> Any: + if type(func) is str: + try: + func = next(self._handlers_iterator(alias=func)) + except StopIteration: + raise ValueError(f"Handler not found", func) + + dp = self.dependency_provider.copy(ctx=self) + resolved_kwargs = dp.resolve_func_params(func, func_args, func_kwargs) + p = partial(func, **resolved_kwargs) + wrapped_handler = self._wrap_with_middlewares(p) + result = wrapped_handler() + return result + + def emit(self, event: str, *args, **kwargs) -> dict[callable, Any]: + """Emit an event and call all event handlers immediately""" + all_results = OrderedDict() + for handler in self._handlers_iterator(alias=event): + result = self.call(handler, *args, **kwargs) + all_results[handler] = result + return all_results + + def get_dependency(self, identifier: Any) -> Any: + """Get a dependency from the dependency provider""" + return self.dependency_provider.get_dependency(identifier) + + def __getitem__(self, item) -> Any: + return self.get_dependency(item) diff --git a/lato/utils.py b/lato/utils.py new file mode 100644 index 0000000..5684c73 --- /dev/null +++ b/lato/utils.py @@ -0,0 +1,16 @@ +from collections import OrderedDict + + +class OrderedSet(OrderedDict): + def __init__(self, iterable=None): + super().__init__() + if iterable: + for item in iterable: + self.add(item) + + def add(self, item): + self[item] = None + + def update(self, iterable): + for item in iterable: + self.add(item) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..b55b2d5 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,600 @@ +[[package]] +name = "build" +version = "1.0.3" +description = "A simple, correct Python build frontend" +category = "dev" +optional = false +python-versions = ">= 3.7" + +[package.dependencies] +colorama = {version = "*", markers = "os_name == \"nt\""} +packaging = ">=19.0" +pyproject_hooks = "*" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["furo (>=2023.08.17)", "sphinx (>=7.0,<8.0)", "sphinx-argparse-cli (>=1.5)", "sphinx-autodoc-typehints (>=1.10)", "sphinx-issues (>=3.0.0)"] +test = ["filelock (>=3)", "pytest (>=6.2.4)", "pytest-cov (>=2.12)", "pytest-mock (>=2)", "pytest-rerunfailures (>=9.1)", "pytest-xdist (>=1.34)", "wheel (>=0.36.0)", "setuptools (>=42.0.0)", "setuptools (>=56.0.0)", "setuptools (>=56.0.0)", "setuptools (>=67.8.0)"] +typing = ["importlib-metadata (>=5.1)", "mypy (>=1.5.0,<1.6.0)", "tomli", "typing-extensions (>=3.7.4.3)"] +virtualenv = ["virtualenv (>=20.0.35)"] + +[[package]] +name = "certifi" +version = "2023.7.22" +description = "Python package for providing Mozilla's CA Bundle." +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "cffi" +version = "1.16.0" +description = "Foreign Function Interface for Python calling C code." +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +category = "dev" +optional = false +python-versions = ">=3.8" + +[[package]] +name = "charset-normalizer" +version = "3.3.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "dev" +optional = false +python-versions = ">=3.7.0" + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + +[[package]] +name = "cryptography" +version = "41.0.4" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["black", "ruff", "mypy", "check-sdist"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist", "pretend"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "distlib" +version = "0.3.7" +description = "Distribution utilities" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "docutils" +version = "0.20.1" +description = "Docutils -- Python Documentation Utilities" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "exceptiongroup" +version = "1.1.3" +description = "Backport of PEP 654 (exception groups)" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "filelock" +version = "3.12.4" +description = "A platform independent file lock." +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.extras] +docs = ["furo (>=2023.7.26)", "sphinx-autodoc-typehints (>=1.24)", "sphinx (>=7.1.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3)", "diff-cover (>=7.7)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-timeout (>=2.1)", "pytest (>=7.4)"] +typing = ["typing-extensions (>=4.7.1)"] + +[[package]] +name = "identify" +version = "2.5.30" +description = "File identification library for Python" +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "importlib-metadata" +version = "6.8.0" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"] +perf = ["ipython"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ruff", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "jaraco.classes" +version = "3.3.0" +description = "Utility functions for Python class constructs" +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +more-itertools = "*" + +[package.extras] +docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ruff", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] + +[[package]] +name = "jeepney" +version = "0.8.0" +description = "Low-level, pure Python DBus protocol wrapper." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest", "pytest-trio", "pytest-asyncio (>=0.17)", "testpath", "trio", "async-timeout"] +trio = ["trio", "async-generator"] + +[[package]] +name = "keyring" +version = "24.2.0" +description = "Store and access your passwords safely." +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +importlib-metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} +"jaraco.classes" = "*" +jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} + +[package.extras] +completion = ["shtab"] +docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-ruff", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code_style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx-book-theme", "jupyter-sphinx"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "more-itertools" +version = "10.1.0" +description = "More routines for operating on iterables, beyond itertools" +category = "dev" +optional = false +python-versions = ">=3.8" + +[[package]] +name = "nh3" +version = "0.2.14" +description = "Ammonia HTML sanitizer Python binding" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +category = "dev" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" + +[[package]] +name = "packaging" +version = "23.2" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "pkginfo" +version = "1.9.6" +description = "Query metadata from sdists / bdists / installed packages." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +testing = ["pytest", "pytest-cov"] + +[[package]] +name = "platformdirs" +version = "3.11.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.24)", "sphinx (>=7.1.1)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest (>=7.4)"] + +[[package]] +name = "pluggy" +version = "1.3.0" +description = "plugin and hook calling mechanisms for python" +category = "main" +optional = false +python-versions = ">=3.8" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "3.5.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pygments" +version = "2.16.1" +description = "Pygments is a syntax highlighting package written in Python." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] +name = "pyproject-hooks" +version = "1.0.0" +description = "Wrappers to call pyproject.toml-based build backend hooks." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} + +[[package]] +name = "pytest" +version = "7.4.2" +description = "pytest: simple powerful testing with Python" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.2" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "readme-renderer" +version = "42.0" +description = "readme_renderer is a library for rendering readme descriptions for Warehouse" +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +docutils = ">=0.13.1" +nh3 = ">=0.2.14" +Pygments = ">=2.5.1" + +[package.extras] +md = ["cmarkgfm (>=0.8.0)"] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + +[[package]] +name = "rfc3986" +version = "2.0.0" +description = "Validating URI References per RFC 3986" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +idna2008 = ["idna"] + +[[package]] +name = "rich" +version = "13.6.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +category = "dev" +optional = false +python-versions = ">=3.7.0" + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "secretstorage" +version = "3.3.3" +description = "Python bindings to FreeDesktop.org Secret Service API" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cryptography = ">=2.0" +jeepney = ">=0.6" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "twine" +version = "4.0.2" +description = "Collection of utilities for publishing packages on PyPI" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +importlib-metadata = ">=3.6" +keyring = ">=15.1" +pkginfo = ">=1.8.1" +readme-renderer = ">=35.0" +requests = ">=2.20" +requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0" +rfc3986 = ">=1.4.0" +rich = ">=12.0.0" +urllib3 = ">=1.26.0" + +[[package]] +name = "urllib3" +version = "2.0.7" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "virtualenv" +version = "20.24.5" +description = "Virtual Python Environment builder" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<4" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx-argparse (>=0.4)", "sphinx (>=7.1.2)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage-enable-subprocess (>=1)", "coverage (>=7.2.7)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "pytest (>=7.4)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[[package]] +name = "zipp" +version = "3.17.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" +optional = false +python-versions = ">=3.8" + +[package.extras] +docs = ["sphinx (>=3.5)", "sphinx (<7.2.5)", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ruff", "jaraco.itertools", "jaraco.functools", "more-itertools", "big-o", "pytest-ignore-flaky", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.10" +content-hash = "1cb9b4735fd749a2960e1ee4b82fe211a0c17fb97cc48bf83777bf099894a836" + +[metadata.files] +build = [] +certifi = [] +cffi = [] +cfgv = [] +charset-normalizer = [] +colorama = [] +cryptography = [] +distlib = [] +docutils = [] +exceptiongroup = [] +filelock = [] +identify = [] +idna = [] +importlib-metadata = [] +iniconfig = [] +"jaraco.classes" = [] +jeepney = [] +keyring = [] +markdown-it-py = [] +mdurl = [] +more-itertools = [] +nh3 = [] +nodeenv = [] +packaging = [] +pkginfo = [] +platformdirs = [] +pluggy = [] +pre-commit = [] +pycparser = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] +pygments = [] +pyproject-hooks = [] +pytest = [] +pywin32-ctypes = [] +pyyaml = [] +readme-renderer = [] +requests = [] +requests-toolbelt = [] +rfc3986 = [] +rich = [] +secretstorage = [] +tomli = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] +twine = [] +urllib3 = [] +virtualenv = [] +zipp = [] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..579143a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[tool.poetry] +name = "lato" +version = "0.1.2" +description = "Lato is a Python microframework designed for building modular monoliths and loosely coupled applications." +authors = ["Przemysław Górecki "] +readme = "README.md" +repository = "https://github.com/pgorecki/lato" +homepage = "https://github.com/pgorecki/lato" +documentation = "https://github.com/pgorecki/lato/docs" +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +license = "MIT" + +[tool.poetry.dependencies] +python = "^3.10" +pytest = "^7.4.2" + +[tool.poetry.dev-dependencies] +pre-commit = "^3.5.0" +build = "^1.0.3" +twine = "^4.0.2" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/modular_application/__init__.py b/tests/modular_application/__init__.py new file mode 100644 index 0000000..b4133c2 --- /dev/null +++ b/tests/modular_application/__init__.py @@ -0,0 +1,51 @@ +import io + +from lato import Application + +from .employee_module import employee_module +from .project_module import project_module + + +class Logger: + def __init__(self): + self.history = [] + + def printlog(self, *args, **kwargs): + output = io.StringIO() + print(*args, file=output, end="", **kwargs) + contents = output.getvalue() + output.close() + self.history.append(contents) + print(*args, **kwargs) + + +def create_app(): + logger = Logger() + + app = Application("Modular Application", logger=logger, printlog=logger.printlog) + app.include_submodule(project_module) + app.include_submodule(employee_module) + + @app.on_enter_transaction_context + def on_enter_transaction_context(ctx): + printlog = ctx[Logger].printlog + ctx.dependency_provider.update(emit=ctx.emit) + printlog("Entering transaction") + + @app.on_exit_transaction_context + def on_exit_transaction_context(ctx, exception): + printlog = ctx[Logger].printlog + if exception: + printlog("Rolling back due to", exception) + else: + printlog("Committing transaction") + + # @app.transaction_middleware + # def useless_middleware(ctx, call_next): + # printlog = ctx[Logger].printlog + # printlog("before call") + # result = call_next() + # printlog("after call") + # return result + + return app diff --git a/tests/modular_application/employee_module.py b/tests/modular_application/employee_module.py new file mode 100644 index 0000000..af956d9 --- /dev/null +++ b/tests/modular_application/employee_module.py @@ -0,0 +1,30 @@ +from lato import ApplicationModule + +employee_module = ApplicationModule("employee") + + +@employee_module.handler +def add_candidate(candidate_id, name, printlog): + printlog(f"Adding candidate {name} with id {candidate_id}") + + +@employee_module.handler +def hire_candidate(candidate_id, emit, printlog): + printlog(f"Hiring candidate {candidate_id}") + emit("candidate_hired", candidate_id) + + +@employee_module.handler +def fire_employee(employee_id, emit, printlog): + printlog(f"Firing employee {employee_id}") + emit("employee_fired", employee_id) + + +@employee_module.on("employee_hired") +def on_employee_hired(employee_id, printlog): + printlog(f"Sending onboarding email to {employee_id}") + + +@employee_module.on("employee_fired") +def on_employee_fired(employee_id, printlog): + printlog(f"Sending exit email to {employee_id}") diff --git a/tests/modular_application/project_module/__init__.py b/tests/modular_application/project_module/__init__.py new file mode 100644 index 0000000..5b1a69b --- /dev/null +++ b/tests/modular_application/project_module/__init__.py @@ -0,0 +1,7 @@ +import importlib + +from lato.application_module import ApplicationModule + +project_module = ApplicationModule("project") +importlib.import_module(".events", __name__) +importlib.import_module(".use_cases", __name__) diff --git a/tests/modular_application/project_module/events.py b/tests/modular_application/project_module/events.py new file mode 100644 index 0000000..990f742 --- /dev/null +++ b/tests/modular_application/project_module/events.py @@ -0,0 +1,6 @@ +from . import project_module + + +@project_module.on("employee_fired") +def on_employee_fired(employee_id, printlog): + printlog(f"Checking if employee {employee_id} is assigned to a project") diff --git a/tests/modular_application/project_module/use_cases.py b/tests/modular_application/project_module/use_cases.py new file mode 100644 index 0000000..ef5181e --- /dev/null +++ b/tests/modular_application/project_module/use_cases.py @@ -0,0 +1,6 @@ +from . import project_module + + +@project_module.handler +def create_project_use_case(project_id, project_name, printlog): + printlog(f"Creating project {project_name} with id {project_id}") diff --git a/tests/test_application.py b/tests/test_application.py new file mode 100644 index 0000000..d43b898 --- /dev/null +++ b/tests/test_application.py @@ -0,0 +1,166 @@ +import pytest +from application import Application +from transaction_context import TransactionContext + + +class FooService: + ... + + +def test_app_transaction_context(): + foo_service = FooService() + app = Application(foo_service=foo_service) + ctx = app.transaction_context() + + assert ctx.dependency_provider is not app.dependency_provider + assert foo_service is ctx[FooService] is app[FooService] + + +def test_app_transaction_context_with_kwargs(): + foo_service = FooService() + app = Application(foo_service=foo_service) + ctx = app.transaction_context(x=1) + + assert ctx.dependency_provider is not app.dependency_provider + assert ( + foo_service + is ctx.dependency_provider[FooService] + is app.dependency_provider[FooService] + ) + assert ctx.dependency_provider["x"] == 1 + + +def test_app_enter_exit_transaction_context(): + app = Application() + + @app.on_enter_transaction_context + def on_enter_transaction_context(ctx): + ctx.entered = True + + @app.on_exit_transaction_context + def on_exit_transaction_context(ctx, exception=None): + ctx.exited = True + + with app.transaction_context() as ctx: + ... + + assert ctx.entered + assert ctx.exited + + +def test_app_call(): + app = Application() + + def add(x, y): + return x + y + + assert app.call(add, 10, 1) == 11 + + +def test_app_call_by_alias(): + app = Application() + + @app.handler("add") + def add(x, y): + return x + y + + assert app.call("add", 10, 1) == 11 + + +def test_app_exception_within_call(): + app = Application() + + @app.on_enter_transaction_context + def on_enter_transaction_context(ctx): + ctx.entered = True + + @app.on_exit_transaction_context + def on_exit_transaction_context(ctx, exception=None): + if exception: + ctx.exception = exception + + def foo(): + raise ValueError() + + with pytest.raises(ValueError) as exc: + with app.transaction_context() as ctx: + ctx.call(foo) + + assert ctx.entered + assert ctx.exception is exc + + +def test_app_uses_middleware(): + app = Application() + + @app.transaction_middleware + def middleware1(ctx, call_next): + ctx["buffer"].append(1) + return call_next() + + @app.transaction_middleware + def middleware2(ctx, call_next): + ctx["buffer"].append(2) + result = call_next() + ctx["buffer"].append(4) + return result + + def foo(ctx: TransactionContext): + ctx["buffer"].append(3) + return "ok" + + with app.transaction_context(buffer=[]) as ctx: + result = ctx.call(foo) + + assert ctx["buffer"] == [1, 2, 3, 4] + assert result == "ok" + + +def test_emitting_and_handling_events(): + app = Application() + + @app.on("sample_event") + def on_sample_event(x, buffer): + buffer.append(f"on_sample_event {x}") + + def sample_use_case(ctx, buffer): + buffer.append("begin sample_use_case") + ctx.emit("sample_event", "foo") + buffer.append("end sample_use_case") + + with app.transaction_context(buffer=[]) as ctx: + ctx.call(sample_use_case) + + assert ctx["buffer"] == [ + "begin sample_use_case", + "on_sample_event foo", + "end sample_use_case", + ] + + +def test_emitting_and_handling_events_uses_middleware(): + app = Application() + + class Counter: + def __init__(self): + self.value = 0 + + def inc(self): + self.value += 1 + + @app.transaction_middleware + def sample_middleware(ctx, call_next): + ctx[Counter].inc() + call_next() + + @app.on("sample_event") + def on_sample_event(): + ... + + def sample_use_case(ctx): + ctx.emit("sample_event", "foo") + + with app.transaction_context(counter=Counter()) as ctx: + ctx.call(sample_use_case) + + assert ctx[Counter].value == 2 diff --git a/tests/test_application_example_from_readme.py b/tests/test_application_example_from_readme.py new file mode 100644 index 0000000..7259dc5 --- /dev/null +++ b/tests/test_application_example_from_readme.py @@ -0,0 +1,38 @@ +from uuid import uuid4 + +from lato import Application, TransactionContext + + +def test_application_example_from_readme(): + class UserService: + def create_user(self, email, password): + ... + + class EmailService: + def send_welcome_email(self, email): + ... + + app = Application( + name="Hello World", + # dependencies + user_service=UserService(), + email_service=EmailService(), + ) + + def create_user_use_case( + email, password, session_id, ctx: TransactionContext, user_service: UserService + ): + # session_id, TransactionContext and UserService are automatically injected by `ctx.call` + print("Session ID:", session_id) + user_service.create_user(email, password) + ctx.emit("user_created", email) + + @app.on("user_created") + def on_user_created(email, email_service: EmailService): + email_service.send_welcome_email(email) + + with app.transaction_context(session_id=uuid4()) as ctx: + # session_id is transaction scoped dependency + result = ctx.call(create_user_use_case, "alice@example.com", "password") + + assert True diff --git a/tests/test_dependency_provider.py b/tests/test_dependency_provider.py new file mode 100644 index 0000000..0ca3889 --- /dev/null +++ b/tests/test_dependency_provider.py @@ -0,0 +1,101 @@ +from dependency_provider import DependencyProvider, get_function_parameters + + +class FooService: + ... + + +def foo(a: int, b: str, c: FooService): + ... + + +def test_create_provider_with_types(): + foo_service = FooService() + dp = DependencyProvider(foo_service=foo_service) + assert dp[FooService] is foo_service + assert dp["foo_service"] is foo_service + + +def test_create_provider_with_primitive_kwarg(): + dp = DependencyProvider(x=1) + assert dp["x"] == 1 + + +def test_create_provider_with_class_instance_arg(): + service = FooService() + dp = DependencyProvider(service) + assert dp[FooService] is service + + +def test_create_provider_with_class_instance_karg(): + service = FooService() + dp = DependencyProvider(service=service) + assert dp[FooService] is service + assert dp["service"] is service + + +def test_create_provider_with_class_instance_arg_and_kwarg_gets_overridden(): + service1 = FooService() + service2 = FooService() + dp = DependencyProvider(service1, service=service2) + assert dp[FooService] is service2 + assert dp["service"] is service2 + + +def test_resolve_custom_primitive_type(): + class Email(str): + ... + + email = Email("john@example.com") + dp = DependencyProvider(email=email) + assert dp[Email] == email + + +def test_get_function_parameters(): + params = get_function_parameters(foo) + assert params["a"] == int + assert params["b"] == str + assert params["c"] == FooService + + +def test_resolve_params_when_empty(): + dp = DependencyProvider() + assert dp.resolve_func_params(foo) == {} + + +def test_resolve_params_by_name(): + dp = DependencyProvider(a=1, b="2") + assert dp.resolve_func_params(foo) == { + "a": 1, + "b": "2", + } + + +def test_resolve_params_with_func_args(): + dp = DependencyProvider() + assert dp.resolve_func_params(foo, func_args=(10,)) == { + "a": 10, + } + + +def test_resolve_params_with_func_kargs(): + dp = DependencyProvider() + assert dp.resolve_func_params(foo, func_kwargs=dict(a=10)) == { + "a": 10, + } + + +def test_resolve_arguments_by_type(): + service = FooService() + dp = DependencyProvider(service) + assert dp.resolve_func_params(foo) == { + "c": service, + } + + +def test_resolve_arguments_of_function_without_type_hints(): + def bar(a): + ... + + dp = DependencyProvider(a=1) + assert dp.resolve_func_params(bar) == dict(a=1) diff --git a/tests/test_modular_application.py b/tests/test_modular_application.py new file mode 100644 index 0000000..b6bff62 --- /dev/null +++ b/tests/test_modular_application.py @@ -0,0 +1,48 @@ +from .modular_application import create_app +from .modular_application.employee_module import add_candidate + + +def test_modular_application(): + app = create_app() + app.call(add_candidate, 1, "Alice") + assert app["logger"].history == [ + "Entering transaction", + "Adding candidate Alice with id 1", + "Committing transaction", + ] + + +def test_get_one_handler(): + app = create_app() + fire_employee_handlers = list(app.iterate_handlers_for("fire_employee")) + + assert len(fire_employee_handlers) == 1 + + +def test_get_multiple_handlers(): + app = create_app() + employee_fired_handlers = list(app.iterate_handlers_for("employee_fired")) + + assert len(employee_fired_handlers) == 2 + + +def test_modular_application_call_by_alias(): + app = create_app() + app.call("add_candidate", 1, "Alice") + assert app["logger"].history == [ + "Entering transaction", + "Adding candidate Alice with id 1", + "Committing transaction", + ] + + +def test_modular_application_emit_events(): + app = create_app() + app.call("fire_employee", 1) + assert app["logger"].history == [ + "Entering transaction", + "Firing employee 1", + "Checking if employee 1 is assigned to a project", + "Sending exit email to 1", + "Committing transaction", + ] diff --git a/tests/test_transaction_context.py b/tests/test_transaction_context.py new file mode 100644 index 0000000..67c5b4f --- /dev/null +++ b/tests/test_transaction_context.py @@ -0,0 +1,39 @@ +from dependency_provider import DependencyProvider +from transaction_context import TransactionContext + + +def add(a, b): + return a + b + + +def test_call_with_kwargs(): + ctx = TransactionContext() + assert ctx.call(add, a=1, b=2) == 3 + + +def test_call_with_args(): + ctx = TransactionContext() + assert ctx.call(add, 1, 2) == 3 + + +def test_call_with_kwargs(): + ctx = TransactionContext() + assert ctx.call(add, a=1, b=2) == 3 + + +def test_call_with_dependencies(): + dp = DependencyProvider(a=1, b=2) + ctx = TransactionContext(dp) + assert ctx.call(add) == 3 + + +def test_call_with_arg_and_dependency(): + dp = DependencyProvider(a=10, b=20) + ctx = TransactionContext(dp) + assert ctx.call(add, 1) == 21 + + +def test_call_with_kwarg_and_dependency(): + dp = DependencyProvider(a=10, b=20) + ctx = TransactionContext(dp) + assert ctx.call(add, b=2) == 12