From 14a0db74cd3699b012757e71388161e3a0ae6e6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 5 May 2024 01:28:39 +0300 Subject: [PATCH 01/11] Constrained add_component() to only work in the initializer --- docs/tutorials/snippets/webnotifier-app4.py | 6 +- docs/userguide/deployment.rst | 3 - examples/tutorial2/webnotifier/app.py | 9 ++- src/asphalt/core/_component.py | 65 +++++++++++---------- tests/test_component.py | 9 +++ tests/test_runner.py | 8 +-- 6 files changed, 57 insertions(+), 43 deletions(-) diff --git a/docs/tutorials/snippets/webnotifier-app4.py b/docs/tutorials/snippets/webnotifier-app4.py index 9f9f68ed..c0276127 100644 --- a/docs/tutorials/snippets/webnotifier-app4.py +++ b/docs/tutorials/snippets/webnotifier-app4.py @@ -15,14 +15,16 @@ class ApplicationComponent(CLIApplicationComponent): - async def start(self) -> None: + def __init__( + self, components: dict[str, dict[str, Any] | None] | None = None + ) -> None: + super().__init__(components) self.add_component( "mailer", backend="smtp", host="your.smtp.server.here", message_defaults={"sender": "your@email.here", "to": "your@email.here"}, ) - await super().start() @inject async def run(self, *, mailer: Mailer = resource()) -> None: diff --git a/docs/userguide/deployment.rst b/docs/userguide/deployment.rst index 6ddb7e43..e2399f76 100644 --- a/docs/userguide/deployment.rst +++ b/docs/userguide/deployment.rst @@ -53,11 +53,8 @@ Suppose you had the following component class as your root component:: def __init__(self, components, data_directory: str): super().__init__(components) self.data_directory = data_directory - - async def start() -> None: self.add_component('mailer', backend='smtp') self.add_component('sqlalchemy') - await super().start() You could then write a configuration file like this:: diff --git a/examples/tutorial2/webnotifier/app.py b/examples/tutorial2/webnotifier/app.py index 58899771..4dcb0701 100644 --- a/examples/tutorial2/webnotifier/app.py +++ b/examples/tutorial2/webnotifier/app.py @@ -1,8 +1,11 @@ """This is the root component for the Asphalt webnotifier tutorial.""" # isort: off +from __future__ import annotations + import logging from difflib import HtmlDiff +from typing import Any from asphalt.core import CLIApplicationComponent, inject, resource from asphalt.mailer import Mailer @@ -13,10 +16,12 @@ class ApplicationComponent(CLIApplicationComponent): - async def start(self) -> None: + def __init__( + self, components: dict[str, dict[str, Any] | None] | None = None + ) -> None: self.add_component("detector", ChangeDetectorComponent) self.add_component("mailer", backend="smtp") - await super().start() + super().__init__(components) @inject async def run( diff --git a/src/asphalt/core/_component.py b/src/asphalt/core/_component.py index 1990db61..83f68ca9 100644 --- a/src/asphalt/core/_component.py +++ b/src/asphalt/core/_component.py @@ -1,7 +1,6 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -from collections import OrderedDict from collections.abc import Coroutine from dataclasses import dataclass, field from logging import getLogger @@ -27,8 +26,6 @@ class Component(metaclass=ABCMeta): """This is the base class for all Asphalt components.""" - __slots__ = () - @abstractmethod async def start(self) -> None: """ @@ -64,12 +61,12 @@ class ContainerComponent(Component): :vartype component_configs: Dict[str, Optional[Dict[str, Any]]] """ - __slots__ = "child_components", "component_configs" + started = False def __init__( self, components: dict[str, dict[str, Any] | None] | None = None ) -> None: - self.child_components: OrderedDict[str, Component] = OrderedDict() + self.child_components: dict[str, Component] = {} self.component_configs = components or {} def add_component( @@ -97,8 +94,14 @@ def add_component( :param config: keyword arguments passed to the component's constructor """ + if self.started: + raise RuntimeError( + "child components cannot be added once the component has been started" + ) + if not isinstance(alias, str) or not alias: raise TypeError("component_alias must be a nonempty string") + if alias in self.child_components: raise ValueError(f'there is already a child component named "{alias}"') @@ -112,22 +115,7 @@ def add_component( self.child_components[alias] = component async def start(self) -> None: - """ - Create child components that have been configured but not yet created and then - calls their :meth:`~Component.start` methods in separate tasks and waits until - they have completed. - - """ - for alias in self.component_configs: - if alias not in self.child_components: - self.add_component(alias) - - async with create_task_group() as tg: - for alias, component in self.child_components.items(): - tg.start_soon( - component.start, - name=f"Starting {qualified_name(component)} ({alias})", - ) + pass class CLIApplicationComponent(ContainerComponent): @@ -160,11 +148,26 @@ async def run(self) -> int | None: component_types = PluginContainer("asphalt.components", Component) -async def start_component( - component: Component, - *, - timeout: float | None = 20, -) -> None: +async def _start_component(component: Component) -> None: + if isinstance(component, ContainerComponent): + # Prevent the add_component() method from adding any more child components + for alias in component.component_configs: + if alias not in component.child_components: + component.add_component(alias) + + component.started = True + async with create_task_group() as tg: + for alias, child_component in component.child_components.items(): + tg.start_soon( + _start_component, + child_component, + name=f"Starting {qualified_name(child_component)} ({alias})", + ) + + await component.start() + + +async def start_component(component: Component, *, timeout: float | None = 20) -> None: """ Start a component and its subcomponents. @@ -196,15 +199,15 @@ async def start_component( "Asphalt component startup watcher task", ) - await component.start() - - # Cancel the startup timeout, if any - if startup_watcher_scope: - startup_watcher_scope.cancel() + await _start_component(component) if startup_scope.cancel_called: raise TimeoutError("timeout starting component") + # Cancel the startup timeout, if any + if startup_watcher_scope: + startup_watcher_scope.cancel() + async def _component_startup_watcher( startup_cancel_scope: CancelScope, diff --git a/tests/test_component.py b/tests/test_component.py index 9f48760f..6e01d3bc 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -117,6 +117,15 @@ async def test_start(self, container: ContainerComponent) -> None: dummy = cast(DummyComponent, container.child_components["dummy"]) assert dummy.started + async def test_add_component_during_start(self) -> None: + class BadContainerComponent(ContainerComponent): + async def start(self) -> None: + self.add_component("foo", ContainerComponent) + + async with Context(): + with pytest.raises(RuntimeError, match="child components cannot be added"): + await start_component(BadContainerComponent()) + class TestCLIApplicationComponent: def test_run_return_none(self, anyio_backend_name: str) -> None: diff --git a/tests/test_runner.py b/tests/test_runner.py index 80fdecc9..ae594b39 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -256,16 +256,14 @@ class StallingComponent(ContainerComponent): def __init__(self, level: int): super().__init__() self.level = level + if self.level < levels: + self.add_component("child1", StallingComponent, level=self.level + 1) + self.add_component("child2", StallingComponent, level=self.level + 1) async def start(self) -> None: if self.level == levels: # Wait forever for a non-existent resource await get_resource(float, wait=True) - else: - self.add_component("child1", StallingComponent, level=self.level + 1) - self.add_component("child2", StallingComponent, level=self.level + 1) - - await super().start() caplog.set_level(logging.INFO) component = StallingComponent(1) From 24fc5f15df6eb5ef35b0018ee36c650fa3ca66c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 5 May 2024 01:37:06 +0300 Subject: [PATCH 02/11] Renamed the `started` property to something less likely to conflict with user code --- src/asphalt/core/_component.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/asphalt/core/_component.py b/src/asphalt/core/_component.py index 83f68ca9..9eb8cd3a 100644 --- a/src/asphalt/core/_component.py +++ b/src/asphalt/core/_component.py @@ -61,7 +61,7 @@ class ContainerComponent(Component): :vartype component_configs: Dict[str, Optional[Dict[str, Any]]] """ - started = False + _component_started = False def __init__( self, components: dict[str, dict[str, Any] | None] | None = None @@ -94,7 +94,7 @@ def add_component( :param config: keyword arguments passed to the component's constructor """ - if self.started: + if self._component_started: raise RuntimeError( "child components cannot be added once the component has been started" ) @@ -155,7 +155,7 @@ async def _start_component(component: Component) -> None: if alias not in component.child_components: component.add_component(alias) - component.started = True + component._component_started = True async with create_task_group() as tg: for alias, child_component in component.child_components.items(): tg.start_soon( From 339294f059d1ddb69743e6761e3749d706a3e8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 6 May 2024 01:09:23 +0300 Subject: [PATCH 03/11] Actually removed ContainerComponent --- docs/api.rst | 1 - docs/tutorials/snippets/webnotifier-app4.py | 5 +- docs/tutorials/webnotifier.rst | 13 +- docs/userguide/components.rst | 30 ++-- docs/userguide/deployment.rst | 15 +- examples/tutorial2/webnotifier/app.py | 6 +- src/asphalt/core/__init__.py | 1 - src/asphalt/core/_component.py | 153 +++++++++++--------- src/asphalt/core/_utils.py | 6 +- tests/test_component.py | 91 ++++++------ tests/test_runner.py | 3 +- 11 files changed, 169 insertions(+), 155 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 1b2207d2..556112c2 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -7,7 +7,6 @@ Components ---------- .. autoclass:: Component -.. autoclass:: ContainerComponent .. autoclass:: CLIApplicationComponent .. autofunction:: start_component diff --git a/docs/tutorials/snippets/webnotifier-app4.py b/docs/tutorials/snippets/webnotifier-app4.py index c0276127..a48d3a0f 100644 --- a/docs/tutorials/snippets/webnotifier-app4.py +++ b/docs/tutorials/snippets/webnotifier-app4.py @@ -15,10 +15,7 @@ class ApplicationComponent(CLIApplicationComponent): - def __init__( - self, components: dict[str, dict[str, Any] | None] | None = None - ) -> None: - super().__init__(components) + def __init__(self) -> None: self.add_component( "mailer", backend="smtp", diff --git a/docs/tutorials/webnotifier.rst b/docs/tutorials/webnotifier.rst index ab5f0def..cd1a4253 100644 --- a/docs/tutorials/webnotifier.rst +++ b/docs/tutorials/webnotifier.rst @@ -92,9 +92,10 @@ you installed in the beginning. The next modification will send the HTML formatt differences to you by email. But, you only have a single component in your app now. To use ``asphalt-mailer``, you -will need to add its component to your application somehow. Enter -:class:`ContainerComponent`. With that, you can create a hierarchy of components where -the ``mailer`` component is a child component of your own container component. +will need to add its component to your application somehow. This is exactly what +:meth:`Component.add_component` is for. With that, you can create a hierarchy of +components where the ``mailer`` component is a child component of your own container +component. To use the mailer resource provided by ``asphalt-mailer``, inject it to the ``run()`` function as a resource by adding a keyword-only argument, annotated with the type of @@ -196,9 +197,9 @@ The ``component`` section defines parameters for the root component. Aside from special ``type`` key which tells the runner where to find the component class, all the keys in this section are passed to the constructor of ``ApplicationComponent`` as keyword arguments. Keys under ``components`` will match the alias of each child -component, which is given as the first argument to -:meth:`ContainerComponent.add_component`. Any component parameters given here can now be -removed from the ``add_component()`` call in ``ApplicationComponent``'s code. +component, which is given as the first argument to :meth:`Component.add_component`. Any +component parameters given here can now be removed from the ``add_component()`` call in +``ApplicationComponent``'s code. The logging configuration here sets up two loggers, one for ``webnotifier`` and its descendants and another (``root``) as a catch-all for everything else. It specifies one diff --git a/docs/userguide/components.rst b/docs/userguide/components.rst index 533722e1..e8100ac5 100644 --- a/docs/userguide/components.rst +++ b/docs/userguide/components.rst @@ -28,19 +28,17 @@ In order to speed up the startup process and to prevent any deadlocks, component try to add any resources as soon as possible before requesting any. If two or more components end up waiting on each others' resources, the application will fail to start. -Container components --------------------- - -A *container component* is component that can contain other Asphalt components. -The root component of virtually any nontrivial Asphalt application is a container -component. Container components can of course contain other container components and so -on. - -When the container component starts its child components, each :meth:`Component.start` -call is launched in its own task. Therefore all the child components start concurrently -and cannot rely on the start order. This is by design. The only way components should be -relying on each other is by the sharing of resources in the context. If a component -needs a resource from its "sibling" component, it should pass the ``wait=True`` option -to :func:`get_resource` in order to block until that resource becomes available. Note, -however, that if that resource is never added by any component in the context, the -application start-up will time out. +Component hierarchies +--------------------- + +Any Asphalt component can have *child components* added to it. When a component is +started by :func:`start_component`, its child components are started first, and only +then is the parent component itself started. The idea is that child components provide +services that either the parent component, or another child component, require to +provide their own services and/or resources call is launched in its own task. Therefore +all the child components start concurrently and cannot rely on the start order. This is +by design. The only way components should be relying on each other is by the sharing of +resources in the context. If a component needs a resource from its "sibling" component, +it should pass the ``wait=True`` option to :func:`get_resource` in order to block until +that resource becomes available. Note, however, that if that resource is never added by +any component in the context, the application start-up will time out. diff --git a/docs/userguide/deployment.rst b/docs/userguide/deployment.rst index e2399f76..b691a958 100644 --- a/docs/userguide/deployment.rst +++ b/docs/userguide/deployment.rst @@ -49,7 +49,7 @@ A production-ready configuration file should contain at least the following opti Suppose you had the following component class as your root component:: - class MyRootComponent(ContainerComponent): + class MyRootComponent(Component): def __init__(self, components, data_directory: str): super().__init__(components) self.data_directory = data_directory @@ -62,13 +62,16 @@ You could then write a configuration file like this:: max_threads: 20 component: type: !!python/name:myproject.MyRootComponent - data_directory: /some/file/somewhere + config: + data_directory: /some/file/somewhere components: mailer: - host: smtp.mycompany.com - ssl: true + config: + host: smtp.mycompany.com + ssl: true sqlalchemy: - url: postgresql:///mydatabase + config: + url: postgresql:///mydatabase logging: version: 1 @@ -148,7 +151,7 @@ Configuration overlays Component configuration can be specified on several levels: -* Hard-coded arguments to :meth:`ContainerComponent.add_component` +* Hard-coded arguments to :meth:`Component.add_component` * First configuration file argument to ``asphalt run`` * Second configuration file argument to ``asphalt run`` * ... diff --git a/examples/tutorial2/webnotifier/app.py b/examples/tutorial2/webnotifier/app.py index 4dcb0701..3fca129a 100644 --- a/examples/tutorial2/webnotifier/app.py +++ b/examples/tutorial2/webnotifier/app.py @@ -5,7 +5,6 @@ import logging from difflib import HtmlDiff -from typing import Any from asphalt.core import CLIApplicationComponent, inject, resource from asphalt.mailer import Mailer @@ -16,12 +15,9 @@ class ApplicationComponent(CLIApplicationComponent): - def __init__( - self, components: dict[str, dict[str, Any] | None] | None = None - ) -> None: + def __init__(self) -> None: self.add_component("detector", ChangeDetectorComponent) self.add_component("mailer", backend="smtp") - super().__init__(components) @inject async def run( diff --git a/src/asphalt/core/__init__.py b/src/asphalt/core/__init__.py index d95db659..8e2a27e4 100644 --- a/src/asphalt/core/__init__.py +++ b/src/asphalt/core/__init__.py @@ -2,7 +2,6 @@ from ._component import CLIApplicationComponent as CLIApplicationComponent from ._component import Component as Component -from ._component import ContainerComponent as ContainerComponent from ._component import start_component as start_component from ._concurrent import TaskFactory as TaskFactory from ._concurrent import TaskHandle as TaskHandle diff --git a/src/asphalt/core/_component.py b/src/asphalt/core/_component.py index 9eb8cd3a..16e26d0b 100644 --- a/src/asphalt/core/_component.py +++ b/src/asphalt/core/_component.py @@ -3,6 +3,7 @@ from abc import ABCMeta, abstractmethod from collections.abc import Coroutine from dataclasses import dataclass, field +from inspect import isclass from logging import getLogger from traceback import StackSummary from types import FrameType @@ -26,51 +27,11 @@ class Component(metaclass=ABCMeta): """This is the base class for all Asphalt components.""" - @abstractmethod - async def start(self) -> None: - """ - Perform any necessary tasks to start the services provided by this component. - - In this method, components typically use the context to: - * add resources and/or resource factories to it - (:func:`add_resource` and :func:`add_resource_factory`) - * get resources from it asynchronously - (:func:`get_resource`) - - It is advisable for Components to first add all the resources they can to the - context before requesting any from it. This will speed up the dependency - resolution and prevent deadlocks. - - .. warning:: It's unadvisable to call this method directly (in case you're doing - it in a test suite). Instead, call :func:`start_component`, as it comes with - extra safeguards. - """ - - -class ContainerComponent(Component): - """ - A component that can contain other components. - - :param components: dictionary of component alias ⭢ component configuration - dictionary - :ivar child_components: dictionary of component alias ⭢ :class:`Component` instance - (of child components added with :meth:`add_component`) - :vartype child_components: Dict[str, Component] - :ivar component_configs: dictionary of component alias ⭢ externally provided - component configuration - :vartype component_configs: Dict[str, Optional[Dict[str, Any]]] - """ - + _child_components: dict[str, dict[str, Any]] | None = None _component_started = False - def __init__( - self, components: dict[str, dict[str, Any] | None] | None = None - ) -> None: - self.child_components: dict[str, Component] = {} - self.component_configs = components or {} - def add_component( - self, alias: str, type: str | type | None = None, **config: Any + self, alias: str, /, type: str | type[Component] | None = None, **config: Any ) -> None: """ Add a child component. @@ -91,7 +52,9 @@ def add_component( :param alias: a name for the component instance, unique within this container :param type: name of and entry point in the ``asphalt.components`` namespace or a :class:`Component` subclass - :param config: keyword arguments passed to the component's constructor + :param config: mapping of keyword arguments passed to the component's + initializer + :raises RuntimeError: if there is already a child component with the same alias """ if self._component_started: @@ -100,25 +63,51 @@ def add_component( ) if not isinstance(alias, str) or not alias: - raise TypeError("component_alias must be a nonempty string") + raise TypeError("alias must be a nonempty string") - if alias in self.child_components: - raise ValueError(f'there is already a child component named "{alias}"') + if type is None: + type = alias - config["type"] = type or alias + if isclass(type): + if not issubclass(type, Component): + raise TypeError( + f"{qualified_name(type)} is not a subclass of " + f"asphalt.core.Component" + ) + elif isinstance(type, str): + component_types.resolve(type) + else: + raise TypeError( + "type must be either a subclass of asphalt.core.Component or a string" + ) - # Allow the external configuration to override the constructor arguments - override_config = self.component_configs.get(alias) or {} - config = merge_config(config, override_config) + if self._child_components is None: + self._child_components = {} + elif alias in self._child_components: + raise ValueError(f'there is already a child component named "{alias}"') - component = component_types.create_object(**config) - self.child_components[alias] = component + self._child_components[alias] = {"type": type, **config} async def start(self) -> None: - pass + """ + Perform any necessary tasks to start the services provided by this component. + In this method, components typically use the context to: + * add resources and/or resource factories to it + (:func:`add_resource` and :func:`add_resource_factory`) + * get resources from it asynchronously + (:func:`get_resource`) -class CLIApplicationComponent(ContainerComponent): + It is advisable for Components to first add all the resources they can to the + context before requesting any from it. This will speed up the dependency + resolution and prevent deadlocks. + + .. warning:: You should never need to call this method directly, as doing so + will only initialize the component itself, and not any child components. + """ + + +class CLIApplicationComponent(Component): """ Specialized subclass of :class:`.ContainerComponent` for command line tools. @@ -148,30 +137,58 @@ async def run(self) -> int | None: component_types = PluginContainer("asphalt.components", Component) -async def _start_component(component: Component) -> None: - if isinstance(component, ContainerComponent): - # Prevent the add_component() method from adding any more child components - for alias in component.component_configs: - if alias not in component.child_components: - component.add_component(alias) +async def _start_component( + component: Component, path: str, components_config: dict[str, Any] +) -> None: + # Merge the overrides to the hard-coded configuration + merged_components_config = merge_config( + component._child_components, components_config + ) + + # Create the child components + child_components_by_alias: dict[str, tuple[Component, dict[str, Any]]] = {} + for alias, child_config in merged_components_config.items(): + component_type = child_config.pop("type") + if isinstance(component_type, str): + component_class = component_types.resolve(component_type) + else: + component_class = component_type + + child_components_config = child_config.pop("components", {}) + child_component = component_class(**child_config) + child_components_by_alias[alias] = (child_component, child_components_config) - component._component_started = True + # Start the child components + if child_components_by_alias: async with create_task_group() as tg: - for alias, child_component in component.child_components.items(): + for alias, ( + child_component, + child_components_config, + ) in child_components_by_alias.items(): + final_path = f"{path}.{alias}" if path else alias tg.start_soon( _start_component, child_component, - name=f"Starting {qualified_name(child_component)} ({alias})", + final_path, + child_components_config, + name=f"Starting {final_path} ({qualified_name(child_component)})", ) + component._component_started = True await component.start() -async def start_component(component: Component, *, timeout: float | None = 20) -> None: +async def start_component( + component: Component, + override_config: dict[str, Any] | None = None, + *, + timeout: float | None = 20, +) -> None: """ Start a component and its subcomponents. :param component: the (root) component to start + :param override_config: configuration overrides for the root component and subcomponents :param timeout: seconds to wait for all the components in the hierarchy to start (default: ``20``; set to ``None`` to disable timeout) :raises RuntimeError: if this function is called without an active :class:`Context` @@ -199,7 +216,7 @@ async def start_component(component: Component, *, timeout: float | None = 20) - "Asphalt component startup watcher task", ) - await _start_component(component) + await _start_component(component, "", override_config or {}) if startup_scope.cancel_called: raise TimeoutError("timeout starting component") @@ -266,7 +283,7 @@ class ComponentStatus: elif task.name and (match := component_task_re.match(task.name)): name: str alias: str - name, alias = match.groups() + alias, name = match.groups() status = ComponentStatus(name, alias, task.parent_id) else: continue @@ -280,10 +297,6 @@ class ComponentStatus: root_status = component_status elif parent_status := component_statuses.get(component_status.parent_task_id): parent_status.children.append(component_status) - if parent_status.alias: - component_status.alias = ( - f"{parent_status.alias}.{component_status.alias}" - ) def format_status(status_: ComponentStatus, level: int) -> str: title = f"{status_.alias or 'root'} ({status_.name})" diff --git a/src/asphalt/core/_utils.py b/src/asphalt/core/_utils.py index 18ac512c..5cfd1701 100644 --- a/src/asphalt/core/_utils.py +++ b/src/asphalt/core/_utils.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from collections.abc import Callable +from collections.abc import Callable, Mapping from functools import partial from importlib import import_module from inspect import isclass @@ -73,7 +73,7 @@ def callable_name(func: Callable[..., Any]) -> str: def merge_config( - original: dict[str, Any] | None, overrides: dict[str, Any] | None + original: Mapping[str, Any] | None, overrides: Mapping[str, Any] | None ) -> dict[str, Any]: """ Return a copy of the ``original`` configuration dictionary, with overrides from @@ -96,7 +96,7 @@ def merge_config( loggers). """ - copied = original.copy() if original else {} + copied = dict(original) if original else {} if overrides: for key, value in overrides.items(): orig_value = copied.get(key) diff --git a/tests/test_component.py b/tests/test_component.py index 6e01d3bc..0f148195 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from typing import Any, NoReturn, cast +from typing import Any, NoReturn from unittest.mock import Mock import anyio @@ -13,7 +13,6 @@ from asphalt.core import ( CLIApplicationComponent, Component, - ContainerComponent, Context, run_application, start_component, @@ -29,13 +28,20 @@ class DummyComponent(Component): - def __init__(self, **kwargs: Any): + def __init__( + self, + alias: str | None = None, + container: dict[str, DummyComponent] | None = None, + **kwargs: Any, + ): self.kwargs = kwargs - self.started = False + self.alias = alias + self.container = container async def start(self) -> None: await anyio.sleep(0.1) - self.started = True + if self.alias and self.container is not None: + self.container[self.alias] = self @pytest.fixture(autouse=True) @@ -45,40 +51,49 @@ def monkeypatch_plugins(monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr(component_types, "_entrypoints", {"dummy": entrypoint}) -class TestContainerComponent: - @pytest.fixture - def container(self) -> ContainerComponent: - return ContainerComponent({"dummy": {"a": 1, "c": 3}}) - - def test_add_component(self, container: ContainerComponent) -> None: +class TestComplexComponent: + @pytest.mark.parametrize( + "component_type", + [ + pytest.param(DummyComponent, id="class"), + pytest.param("dummy", id="entrypoint"), + ], + ) + async def test_add_component(self, component_type: type[Component] | str) -> None: """ Test that add_component works with an without an entry point and that external configuration overriddes directly supplied configuration values. """ - container.add_component("dummy", DummyComponent, a=5, b=2) - - assert len(container.child_components) == 1 - component = container.child_components["dummy"] - assert isinstance(component, DummyComponent) - assert component.kwargs == {"a": 1, "b": 2, "c": 3} - - def test_add_component_with_type(self) -> None: - """ - Test that add_component works with a `type` specified in a - configuration overriddes directly supplied configuration values. + components_container: dict[str, DummyComponent] = {} + container = Component() + container.add_component( + "dummy1", + component_type, + alias="dummy1", + container=components_container, + a=5, + b=2, + ) + container.add_component( + "dummy2", + component_type, + alias="dummy2", + container=components_container, + a=8, + b=7, + ) + async with Context(): + await start_component(container) - """ - container = ContainerComponent({"dummy": {"type": DummyComponent}}) - container.add_component("dummy") - assert len(container.child_components) == 1 - component = container.child_components["dummy"] - assert isinstance(component, DummyComponent) + assert len(components_container) == 2 + assert components_container["dummy1"].kwargs == {"a": 5, "b": 2} + assert components_container["dummy2"].kwargs == {"a": 8, "b": 7} @pytest.mark.parametrize( "alias, cls, exc_cls, message", [ - ("", None, TypeError, "component_alias must be a nonempty string"), + ("", None, TypeError, "alias must be a nonempty string"), ( "foo", None, @@ -96,31 +111,25 @@ def test_add_component_with_type(self) -> None: ) def test_add_component_errors( self, - container: ContainerComponent, alias: str, cls: type | None, exc_cls: type[Exception], message: str, ) -> None: + container = Component() exc = pytest.raises(exc_cls, container.add_component, alias, cls) assert str(exc.value) == message - def test_add_duplicate_component(self, container: ContainerComponent) -> None: + def test_add_duplicate_component(self) -> None: + container = Component() container.add_component("dummy") exc = pytest.raises(ValueError, container.add_component, "dummy") assert str(exc.value) == 'there is already a child component named "dummy"' - async def test_start(self, container: ContainerComponent) -> None: - async with Context(): - await start_component(container) - - dummy = cast(DummyComponent, container.child_components["dummy"]) - assert dummy.started - async def test_add_component_during_start(self) -> None: - class BadContainerComponent(ContainerComponent): + class BadContainerComponent(Component): async def start(self) -> None: - self.add_component("foo", ContainerComponent) + self.add_component("foo", DummyComponent) async with Context(): with pytest.raises(RuntimeError, match="child components cannot be added"): @@ -185,7 +194,7 @@ async def test_start_component_no_context() -> None: with pytest.raises( RuntimeError, match=r"start_component\(\) requires an active Asphalt context" ): - await start_component(ContainerComponent()) + await start_component(DummyComponent()) async def test_start_component_timeout() -> None: diff --git a/tests/test_runner.py b/tests/test_runner.py index ae594b39..d77e56ec 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -16,7 +16,6 @@ from asphalt.core import ( CLIApplicationComponent, Component, - ContainerComponent, add_teardown_callback, get_resource, run_application, @@ -252,7 +251,7 @@ def test_start_exception( def test_start_timeout( caplog: LogCaptureFixture, anyio_backend_name: str, levels: int ) -> None: - class StallingComponent(ContainerComponent): + class StallingComponent(Component): def __init__(self, level: int): super().__init__() self.level = level From 4090d72b1e6ee9a2c74dc15bd330b31a7cd905dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 6 May 2024 19:09:41 +0300 Subject: [PATCH 04/11] Improved test coverage --- tests/test_component.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/test_component.py b/tests/test_component.py index 0f148195..1c0bb08d 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -93,21 +93,31 @@ async def test_add_component(self, component_type: type[Component] | str) -> Non @pytest.mark.parametrize( "alias, cls, exc_cls, message", [ - ("", None, TypeError, "alias must be a nonempty string"), - ( + pytest.param( + "", None, TypeError, "alias must be a nonempty string", id="empty_alias" + ), + pytest.param( "foo", None, LookupError, "no such entry point in asphalt.components: foo", + id="bogus_entry_point", ), - ( + pytest.param( "foo", int, TypeError, "int is not a subclass of asphalt.core.Component", + id="wrong_subclass", + ), + pytest.param( + "foo", + 4, + TypeError, + "type must be either a subclass of asphalt.core.Component or a string", + id="invalid_type", ), ], - ids=["empty_alias", "bogus_entry_point", "wrong_subclass"], ) def test_add_component_errors( self, From bab994b3c7820e8d13c75b5d6b0a5ccc0619866b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sat, 8 Jun 2024 16:30:39 +0300 Subject: [PATCH 05/11] Added the prepare() method to the Component class --- src/asphalt/core/_component.py | 19 +++++++++++++++++-- tests/test_component.py | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/asphalt/core/_component.py b/src/asphalt/core/_component.py index 16e26d0b..f7226e87 100644 --- a/src/asphalt/core/_component.py +++ b/src/asphalt/core/_component.py @@ -59,7 +59,8 @@ def add_component( """ if self._component_started: raise RuntimeError( - "child components cannot be added once the component has been started" + "child components cannot be added once start_component() has been " + "called on the component" ) if not isinstance(alias, str) or not alias: @@ -88,6 +89,15 @@ def add_component( self._child_components[alias] = {"type": type, **config} + async def prepare(self) -> None: + """ + Perform any necessary initialization before starting the component. + + This method is called by :func:`start_component` before starting the child + components of this component, so it can be used to add any resources required + by the child components. + """ + async def start(self) -> None: """ Perform any necessary tasks to start the services provided by this component. @@ -140,6 +150,9 @@ async def run(self) -> int | None: async def _start_component( component: Component, path: str, components_config: dict[str, Any] ) -> None: + # Prevent add_component() from being called beyond this point + component._component_started = True + # Merge the overrides to the hard-coded configuration merged_components_config = merge_config( component._child_components, components_config @@ -158,6 +171,9 @@ async def _start_component( child_component = component_class(**child_config) child_components_by_alias[alias] = (child_component, child_components_config) + # Call prepare() on the component itself + await component.prepare() + # Start the child components if child_components_by_alias: async with create_task_group() as tg: @@ -174,7 +190,6 @@ async def _start_component( name=f"Starting {final_path} ({qualified_name(child_component)})", ) - component._component_started = True await component.start() diff --git a/tests/test_component.py b/tests/test_component.py index 1c0bb08d..ab040add 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -14,6 +14,8 @@ CLIApplicationComponent, Component, Context, + add_resource, + get_resource_nowait, run_application, start_component, ) @@ -216,3 +218,24 @@ async def start(self) -> None: async with Context(): with pytest.raises(TimeoutError, match="timeout starting component"): await start_component(StallingComponent(), timeout=0.01) + + +async def test_prepare() -> None: + class ParentComponent(Component): + def __init__(self) -> None: + self.add_component("child", ChildComponent) + + async def prepare(self) -> None: + add_resource("foo") + + async def start(self) -> None: + get_resource_nowait(str, "bar") + + class ChildComponent(Component): + async def start(self) -> None: + foo = get_resource_nowait(str) + add_resource(foo + "bar", "bar") + + async with Context(): + await start_component(ParentComponent()) + assert get_resource_nowait(str, "bar") == "foobar" From 7491538af1b8d92e821c7c21143d36c9bcdc0794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sat, 8 Jun 2024 22:32:36 +0300 Subject: [PATCH 06/11] Updated the docstrings for better accuracy --- src/asphalt/core/_component.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/asphalt/core/_component.py b/src/asphalt/core/_component.py index f7226e87..8b7bb3b4 100644 --- a/src/asphalt/core/_component.py +++ b/src/asphalt/core/_component.py @@ -36,9 +36,11 @@ def add_component( """ Add a child component. - This will instantiate a component class, as specified by the ``type`` argument. + This will store the type and configuration options of the named child component, + to be later instantiated by :func:`start_component`. - If the second argument is omitted, the value of ``alias`` is used as its value. + If the ``type`` argument is omitted, then the value of the ``alias`` argument is + used to derive the type. The locally given configuration can be overridden by component configuration parameters supplied to the constructor (via the ``components`` argument). @@ -93,7 +95,7 @@ async def prepare(self) -> None: """ Perform any necessary initialization before starting the component. - This method is called by :func:`start_component` before starting the child + This method is called by :func:`start_component` *before* starting the child components of this component, so it can be used to add any resources required by the child components. """ @@ -102,18 +104,12 @@ async def start(self) -> None: """ Perform any necessary tasks to start the services provided by this component. - In this method, components typically use the context to: - * add resources and/or resource factories to it - (:func:`add_resource` and :func:`add_resource_factory`) - * get resources from it asynchronously - (:func:`get_resource`) + This method is called by :func:`start_component` *after* the child components of + this component have been started, so any resources provided by the child + components are available at this point. - It is advisable for Components to first add all the resources they can to the - context before requesting any from it. This will speed up the dependency - resolution and prevent deadlocks. - - .. warning:: You should never need to call this method directly, as doing so - will only initialize the component itself, and not any child components. + .. warning:: Do not call this method directly; use :func:`start_component` + instead. """ From 272526d799f4c8735518b2d1c6d15d20bd9f96a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 9 Jun 2024 17:32:08 +0300 Subject: [PATCH 07/11] Refactored run_component() to (only) accept either a dict or a component class --- docs/tutorials/snippets/echo1.py | 3 +- docs/tutorials/snippets/webnotifier-app1.py | 2 +- docs/tutorials/snippets/webnotifier-app2.py | 2 +- docs/tutorials/snippets/webnotifier-app3.py | 2 +- docs/tutorials/snippets/webnotifier-app4.py | 2 +- docs/userguide/components.rst | 130 +++++++++++++----- docs/userguide/snippets/components1.py | 49 +++++++ examples/tutorial1/echo/client.py | 9 +- examples/tutorial1/echo/server.py | 3 +- .../tutorial1/tests/test_client_server.py | 8 +- src/asphalt/core/_component.py | 118 +++++++++++----- src/asphalt/core/_runner.py | 62 +++++++-- tests/test_component.py | 57 ++++---- tests/test_runner.py | 78 +++++------ 14 files changed, 360 insertions(+), 165 deletions(-) create mode 100644 docs/userguide/snippets/components1.py diff --git a/docs/tutorials/snippets/echo1.py b/docs/tutorials/snippets/echo1.py index 811b9787..d0ef197d 100644 --- a/docs/tutorials/snippets/echo1.py +++ b/docs/tutorials/snippets/echo1.py @@ -10,5 +10,4 @@ async def start(self) -> None: if __name__ == "__main__": - component = ServerComponent() - run_application(component) + run_application(ServerComponent) diff --git a/docs/tutorials/snippets/webnotifier-app1.py b/docs/tutorials/snippets/webnotifier-app1.py index 7ecb884f..12c73891 100644 --- a/docs/tutorials/snippets/webnotifier-app1.py +++ b/docs/tutorials/snippets/webnotifier-app1.py @@ -17,4 +17,4 @@ async def run(self) -> None: if __name__ == "__main__": - run_application(ApplicationComponent(), logging=logging.DEBUG) + run_application(ApplicationComponent, logging=logging.DEBUG) diff --git a/docs/tutorials/snippets/webnotifier-app2.py b/docs/tutorials/snippets/webnotifier-app2.py index 197b5bb9..ac781b92 100644 --- a/docs/tutorials/snippets/webnotifier-app2.py +++ b/docs/tutorials/snippets/webnotifier-app2.py @@ -29,4 +29,4 @@ async def run(self) -> None: if __name__ == "__main__": - run_application(ApplicationComponent(), logging=logging.DEBUG) + run_application(ApplicationComponent, logging=logging.DEBUG) diff --git a/docs/tutorials/snippets/webnotifier-app3.py b/docs/tutorials/snippets/webnotifier-app3.py index 3f7ea3af..ee18c8f6 100644 --- a/docs/tutorials/snippets/webnotifier-app3.py +++ b/docs/tutorials/snippets/webnotifier-app3.py @@ -36,4 +36,4 @@ async def run(self) -> None: if __name__ == "__main__": - run_application(ApplicationComponent(), logging=logging.DEBUG) + run_application(ApplicationComponent, logging=logging.DEBUG) diff --git a/docs/tutorials/snippets/webnotifier-app4.py b/docs/tutorials/snippets/webnotifier-app4.py index a48d3a0f..5a2447c7 100644 --- a/docs/tutorials/snippets/webnotifier-app4.py +++ b/docs/tutorials/snippets/webnotifier-app4.py @@ -51,4 +51,4 @@ async def run(self, *, mailer: Mailer = resource()) -> None: if __name__ == "__main__": - run_application(ApplicationComponent(), logging=logging.DEBUG) + run_application(ApplicationComponent, logging=logging.DEBUG) diff --git a/docs/userguide/components.rst b/docs/userguide/components.rst index e8100ac5..e6007c8b 100644 --- a/docs/userguide/components.rst +++ b/docs/userguide/components.rst @@ -6,39 +6,107 @@ Working with components Components are the basic building blocks of an Asphalt application. They have a narrowly defined set of responsibilities: -#. Take in configuration through the constructor +#. Take in configuration through the initializer #. Validate the configuration -#. Add resources to the context (in :meth:`Component.start`) +#. Add resources to the context (in either :meth:`Component.prepare`, + :meth:`Component.start` or both) #. Close/shut down/clean up resources when the context is torn down (by directly adding a callback on the context with :meth:`Context.add_teardown_callback`, or by using :func:`context_teardown`) -The :meth:`Component.start` method is called either by the parent component or the -application runner (:func:`run_application`). The component can use the context to add -resources for other components and the application business logic to use. It can also -request resources provided by other components to provide some complex service that -builds on those resources. - -The :meth:`Component.start` method of a component is only called once, during -application startup. When all components have been started, they are disposed of. If any -of the components raises an exception, the application startup process fails and any -context teardown callbacks scheduled so far are called before the process is exited. - -In order to speed up the startup process and to prevent any deadlocks, components should -try to add any resources as soon as possible before requesting any. If two or more -components end up waiting on each others' resources, the application will fail to start. - -Component hierarchies ---------------------- - -Any Asphalt component can have *child components* added to it. When a component is -started by :func:`start_component`, its child components are started first, and only -then is the parent component itself started. The idea is that child components provide -services that either the parent component, or another child component, require to -provide their own services and/or resources call is launched in its own task. Therefore -all the child components start concurrently and cannot rely on the start order. This is -by design. The only way components should be relying on each other is by the sharing of -resources in the context. If a component needs a resource from its "sibling" component, -it should pass the ``wait=True`` option to :func:`get_resource` in order to block until -that resource becomes available. Note, however, that if that resource is never added by -any component in the context, the application start-up will time out. +Any Asphalt component can have *child components* added to it. Child components can +either provide resources required by the parent component, or extend the parent +component's functionality in some way. + +For example, a web application component typically has child components provide +functionality like database access, job queues, and/or integrations with third party +services. Likewise, child components might also extend the web application by adding +new routes to it. + +Component startup +----------------- + +To start a component, be it a solitary component or the root component of a hierarchy, +call :func:`start_component` from within an active :class:`Context` and pass it the +component class as the first positional argument, and its configuration options as the +second argument. + +.. warning:: **NEVER** start components by directly calling :meth:`Component.start`! + While this may work for simple components, more complex components may fail to start + as their child components are not started, nor is the :meth:`Component.prepare` + method never called this way. + +The sequence of events when a component is started by :func:`start_component`, goes as +follows: + +#. The entire hierarchy of components is instantiated using the combination of + hard-coded defaults (as passed to :meth:`Component.add_component`) and any + configuration overrides +#. The component's :meth:`~Component.prepare` method is called +#. All child components of this component are started concurrently (starting from the + :meth:`~Component.prepare` step) +#. The component's :meth:`~Component.start` method is called + +For example, let's say you have the following components: + +.. literalinclude:: snippets/components1.py + +You should see the following lines in the output: + +.. code-block:: text + + ParentComponent.prepare() + ChildComponent.prepare() [child1] + ChildComponent.start() [child1] + ChildComponent.prepare() [child2] + ChildComponent.start() [child2] + ParentComponent.start() + Hello, world from child1! + Hello, world from child2! + +As you can see from the output, the parent component's :meth:`~Component.prepare` method +is called first. Then, the child components are started, and their +:meth:`~Component.prepare` methods are called first, then :meth:`~Component.start`. +When all the child components have been started, only then is the parent component +started. + +The parent component can only use resources from the child components in its +:meth:`~Component.start` method, as only then have the child components that provide +those resources been started. Conversely, the child components cannot depend on +resources added by the parent in its :meth:`~Component.start` method, as this method is +only run after the child components have already been started. + +As ``child1`` and ``child2`` are started concurrently, they are able to use +:func:`get_resource` to request resources from each other. Just make sure they don't get +deadlocked by depending on resources provided by each other at the same time, in which +case both would be stuck waiting forever. + +As a recap, here is what components can do with resources relative to their parent, +sibling and child components: + +* Initializer (``__init__()``): + + * ❌ Cannot acquire resources + * ❌ Cannot provide resources + +* :meth:`Component.prepare`: + + * ✅ Can acquire resources provided by parent components in their + :meth:`~Component.prepare` methods + * ❌ Cannot acquire resources provided by parent components in their + :meth:`~Component.start` methods + * ✅ Can acquire resources provided by sibling components (but you must use + :func:`get_resource` to avoid race conditions) + * ❌ Cannot acquire resources provided by child components + * ✅ Can provide resources to child components + +* :meth:`Component.start`: + + * ✅ Can acquire resources provided by parent components in their + :meth:`~Component.prepare` methods + * ❌ Cannot acquire resources provided by parent components in their + :meth:`~Component.start` methods + * ✅ Can acquire resources provided by sibling components (but you must use + :func:`get_resource` to avoid race conditions) + * ✅ Can acquire resources provided by child components + * ❌ Cannot provide resources to child components diff --git a/docs/userguide/snippets/components1.py b/docs/userguide/snippets/components1.py new file mode 100644 index 00000000..129694df --- /dev/null +++ b/docs/userguide/snippets/components1.py @@ -0,0 +1,49 @@ +from asphalt.core import ( + Component, + add_resource, + get_resource, + get_resource_nowait, + run_application, +) + + +class ParentComponent(Component): + def __init__(self) -> None: + self.add_component("child1", ChildComponent, name="child1") + self.add_component("child2", ChildComponent, name="child2") + + async def prepare(self) -> None: + print("ParentComponent.prepare()") + add_resource("Hello") # adds a `str` type resource by the name `default` + + async def start(self) -> None: + print("ParentComponent.start()") + print(get_resource_nowait(str, "child1_resource")) + print(get_resource_nowait(str, "child2_resource")) + + +class ChildComponent(Component): + parent_resource: str + sibling_resource: str + + def __init__(self, name: str) -> None: + self.name = name + + async def prepare(self) -> None: + self.parent_resource = get_resource_nowait(str) + print(f"ChildComponent.prepare() [{self.name}]") + + async def start(self) -> None: + print(f"ChildComponent.start() [{self.name}]") + + # Add a `str` type resource, with a name like `childX_resource` + add_resource( + f"{self.parent_resource}, world from {self.name}!", f"{self.name}_resource" + ) + + # Do this only after adding our own resource, or we end up in a deadlock + resource = "child1_resource" if self.name == "child2" else "child1_resource" + await get_resource(str, resource) + + +run_application(ParentComponent) diff --git a/examples/tutorial1/echo/client.py b/examples/tutorial1/echo/client.py index 60bc3b78..60654f76 100644 --- a/examples/tutorial1/echo/client.py +++ b/examples/tutorial1/echo/client.py @@ -2,15 +2,15 @@ # isort: off import sys +from dataclasses import dataclass import anyio from asphalt.core import CLIApplicationComponent, run_application +@dataclass class ClientComponent(CLIApplicationComponent): - def __init__(self, message: str): - super().__init__() - self.message = message + message: str async def run(self) -> None: async with await anyio.connect_tcp("localhost", 64100) as stream: @@ -21,5 +21,4 @@ async def run(self) -> None: if __name__ == "__main__": - component = ClientComponent(sys.argv[1]) - run_application(component) + run_application(ClientComponent, {"message": sys.argv[1]}) diff --git a/examples/tutorial1/echo/server.py b/examples/tutorial1/echo/server.py index 5f9e23a0..7f78f50c 100644 --- a/examples/tutorial1/echo/server.py +++ b/examples/tutorial1/echo/server.py @@ -32,5 +32,4 @@ async def start(self) -> None: if __name__ == "__main__": - component = ServerComponent() - run_application(component) + run_application(ServerComponent) diff --git a/examples/tutorial1/tests/test_client_server.py b/examples/tutorial1/tests/test_client_server.py index aef501f7..3cef8df6 100644 --- a/examples/tutorial1/tests/test_client_server.py +++ b/examples/tutorial1/tests/test_client_server.py @@ -17,16 +17,14 @@ @pytest.fixture async def server(capsys: CaptureFixture[str]) -> AsyncGenerator[None, None]: async with Context(): - server = ServerComponent() - await start_component(server) + await start_component(ServerComponent) yield async def test_client_and_server(server: None, capsys: CaptureFixture[str]) -> None: async with Context(): - client = ClientComponent("Hello!") - await start_component(client) - await client.run() + component = await start_component(ClientComponent, {"message": "Hello!"}) + await component.run() # Grab the captured output of sys.stdout and sys.stderr from the capsys fixture await wait_all_tasks_blocked() diff --git a/src/asphalt/core/_component.py b/src/asphalt/core/_component.py index 8b7bb3b4..519daeee 100644 --- a/src/asphalt/core/_component.py +++ b/src/asphalt/core/_component.py @@ -7,7 +7,7 @@ from logging import getLogger from traceback import StackSummary from types import FrameType -from typing import Any +from typing import Any, TypeVar, overload from anyio import ( CancelScope, @@ -23,6 +23,8 @@ from ._exceptions import NoCurrentContext from ._utils import PluginContainer, merge_config, qualified_name +TComponent = TypeVar("TComponent", bound="Component") + class Component(metaclass=ABCMeta): """This is the base class for all Asphalt components.""" @@ -143,63 +145,98 @@ async def run(self) -> int | None: component_types = PluginContainer("asphalt.components", Component) -async def _start_component( - component: Component, path: str, components_config: dict[str, Any] -) -> None: - # Prevent add_component() from being called beyond this point - component._component_started = True +def _init_component( + component_or_config: Component | dict[str, Any], + path: str, + child_components_by_alias: dict[str, dict[str, Component]], +) -> Component: + # Separate the child components from the config + if isinstance(component_or_config, Component): + component = component_or_config + child_components_config = component._child_components or {} + else: + child_components_config = component_or_config.pop("components", {}) - # Merge the overrides to the hard-coded configuration - merged_components_config = merge_config( - component._child_components, components_config - ) + # Resolve the type to a class + component_type = component_or_config.pop("type") + component_class = component_types.resolve(component_type) + + # Instantiate the component + component = component_class(**component_or_config) + + # Merge the overrides to the hard-coded configuration + child_components_config = merge_config( + component._child_components, child_components_config + ) # Create the child components - child_components_by_alias: dict[str, tuple[Component, dict[str, Any]]] = {} - for alias, child_config in merged_components_config.items(): - component_type = child_config.pop("type") - if isinstance(component_type, str): - component_class = component_types.resolve(component_type) - else: - component_class = component_type + child_components = child_components_by_alias[path] = {} + for alias, child_config in child_components_config.items(): + final_path = f"{path}.{alias}" if path else alias + child_component = _init_component( + child_config, final_path, child_components_by_alias + ) + child_components[alias] = child_component + + return component - child_components_config = child_config.pop("components", {}) - child_component = component_class(**child_config) - child_components_by_alias[alias] = (child_component, child_components_config) + +async def _start_component( + component: Component, + path: str, + child_components_by_alias: dict[str, dict[str, Component]], +) -> None: + # Prevent add_component() from being called beyond this point + component._component_started = True # Call prepare() on the component itself await component.prepare() # Start the child components - if child_components_by_alias: + if child_components := child_components_by_alias.get(path): async with create_task_group() as tg: - for alias, ( - child_component, - child_components_config, - ) in child_components_by_alias.items(): + for alias, child_component in child_components.items(): final_path = f"{path}.{alias}" if path else alias tg.start_soon( _start_component, child_component, final_path, - child_components_config, + child_components_by_alias, name=f"Starting {final_path} ({qualified_name(child_component)})", ) await component.start() +@overload async def start_component( - component: Component, - override_config: dict[str, Any] | None = None, + config_or_component_class: type[TComponent], + config: dict[str, Any] | None = ..., + *, + timeout: float | None = ..., +) -> TComponent: ... + + +@overload +async def start_component( + config_or_component_class: dict[str, Any], + config: dict[str, Any] | None = ..., + *, + timeout: float | None = ..., +) -> Component: ... + + +async def start_component( + config_or_component_class: type[Component] | dict[str, Any], + config: dict[str, Any] | None = None, *, timeout: float | None = 20, -) -> None: +) -> Component: """ Start a component and its subcomponents. - :param component: the (root) component to start - :param override_config: configuration overrides for the root component and subcomponents + :param config_or_component_class: the (root) component to start, or a configuration + :param config: configuration overrides for the root component and subcomponents :param timeout: seconds to wait for all the components in the hierarchy to start (default: ``20``; set to ``None`` to disable timeout) :raises RuntimeError: if this function is called without an active :class:`Context` @@ -207,6 +244,16 @@ async def start_component( ``timeout`` seconds """ + if isinstance(config_or_component_class, dict): + configuration = config_or_component_class + elif issubclass(config_or_component_class, Component): + configuration = config or {} + configuration["type"] = config_or_component_class + else: + raise TypeError( + "config_or_component_class must either be a Component subclass or a dict" + ) + try: current_context() except NoCurrentContext: @@ -214,20 +261,23 @@ async def start_component( "start_component() requires an active Asphalt context" ) from None + child_components_by_alias: dict[str, dict[str, Component]] = {} + root_component = _init_component(configuration, "", child_components_by_alias) + with CancelScope() as startup_scope: startup_watcher_scope: CancelScope | None = None if timeout is not None: startup_watcher_scope = await start_service_task( lambda task_status: _component_startup_watcher( startup_scope, - component, + root_component, timeout, task_status=task_status, ), "Asphalt component startup watcher task", ) - await _start_component(component, "", override_config or {}) + await _start_component(root_component, "", child_components_by_alias) if startup_scope.cancel_called: raise TimeoutError("timeout starting component") @@ -236,6 +286,8 @@ async def start_component( if startup_watcher_scope: startup_watcher_scope.cancel() + return root_component + async def _component_startup_watcher( startup_cancel_scope: CancelScope, diff --git a/src/asphalt/core/_runner.py b/src/asphalt/core/_runner.py index 1fafb0aa..edf80213 100644 --- a/src/asphalt/core/_runner.py +++ b/src/asphalt/core/_runner.py @@ -7,7 +7,7 @@ from functools import partial from logging import INFO, Logger, basicConfig, getLogger from logging.config import dictConfig -from typing import Any, cast +from typing import Any, overload from warnings import warn import anyio @@ -22,7 +22,6 @@ from ._component import ( CLIApplicationComponent, Component, - component_types, start_component, ) from ._concurrent import start_service_task @@ -47,7 +46,8 @@ async def handle_signals( async def _run_application_async( - component: Component, + config_or_component_class: type[Component] | dict[str, Any], + config: dict[str, Any] | None, logger: Logger, max_threads: int | None, start_timeout: float | None, @@ -70,9 +70,8 @@ async def _run_application_async( ) try: - await start_component( - component, - timeout=start_timeout, + component = await start_component( + config_or_component_class, config, timeout=start_timeout ) except (get_cancelled_exc_class(), TimeoutError): # This happens when a signal handler cancels the startup or @@ -106,8 +105,46 @@ async def _run_application_async( logger.info("Application stopped") +@overload def run_application( - component: Component | dict[str, Any], + config_or_component_class: type[Component], + config: dict[str, Any], + *, + backend: str = "asyncio", + backend_options: dict[str, Any] | None = None, + max_threads: int | None = None, + logging: dict[str, Any] | int | None = INFO, + start_timeout: int | float | None = 10, +) -> None: ... + + +@overload +def run_application( + config_or_component_class: type[Component], + *, + backend: str = "asyncio", + backend_options: dict[str, Any] | None = None, + max_threads: int | None = None, + logging: dict[str, Any] | int | None = INFO, + start_timeout: int | float | None = 10, +) -> None: ... + + +@overload +def run_application( + config_or_component_class: dict[str, Any], + *, + backend: str = "asyncio", + backend_options: dict[str, Any] | None = None, + max_threads: int | None = None, + logging: dict[str, Any] | int | None = INFO, + start_timeout: int | float | None = 10, +) -> None: ... + + +def run_application( + config_or_component_class: type[Component] | dict[str, Any], + config: dict[str, Any] | None = None, *, backend: str = "asyncio", backend_options: dict[str, Any] | None = None, @@ -136,8 +173,8 @@ def run_application( is set to the value of ``max_threads`` or, if omitted, the default value of :class:`~concurrent.futures.ThreadPoolExecutor`. - :param component: the root component (either a component instance or a configuration - dictionary where the special ``type`` key is a component class + :param config_or_component_class: the root component (either a component instance or a + configuration dictionary where the special ``type`` key is a component class) :param backend: name of the AnyIO backend (e.g. ``asyncio`` or ``trio``) :param backend_options: options to pass to the AnyIO backend (see the `AnyIO documentation`_ for reference) @@ -164,13 +201,10 @@ def run_application( logger = getLogger(__name__) logger.info("Running in %s mode", "development" if __debug__ else "production") - # Instantiate the root component if a dict was given - if isinstance(component, dict): - component = cast(Component, component_types.create_object(**component)) - if exit_code := anyio.run( _run_application_async, - component, + config_or_component_class, + config, logger, max_threads, start_timeout, diff --git a/tests/test_component.py b/tests/test_component.py index ab040add..cd9a5625 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -68,25 +68,28 @@ async def test_add_component(self, component_type: type[Component] | str) -> Non """ components_container: dict[str, DummyComponent] = {} - container = Component() - container.add_component( - "dummy1", - component_type, - alias="dummy1", - container=components_container, - a=5, - b=2, - ) - container.add_component( - "dummy2", - component_type, - alias="dummy2", - container=components_container, - a=8, - b=7, - ) + + class ContainerComponent(Component): + def __init__(self) -> None: + self.add_component( + "dummy1", + component_type, + alias="dummy1", + container=components_container, + a=5, + b=2, + ) + self.add_component( + "dummy2", + component_type, + alias="dummy2", + container=components_container, + a=8, + b=7, + ) + async with Context(): - await start_component(container) + await start_component(ContainerComponent) assert len(components_container) == 2 assert components_container["dummy1"].kwargs == {"a": 5, "b": 2} @@ -145,7 +148,7 @@ async def start(self) -> None: async with Context(): with pytest.raises(RuntimeError, match="child components cannot be added"): - await start_component(BadContainerComponent()) + await start_component(BadContainerComponent) class TestCLIApplicationComponent: @@ -155,7 +158,7 @@ async def run(self) -> None: pass # No exception should be raised here - run_application(DummyCLIComponent(), backend=anyio_backend_name) + run_application(DummyCLIComponent, backend=anyio_backend_name) def test_run_return_5(self, anyio_backend_name: str) -> None: class DummyCLIComponent(CLIApplicationComponent): @@ -163,7 +166,7 @@ async def run(self) -> int: return 5 with pytest.raises(SystemExit) as exc: - run_application(DummyCLIComponent(), backend=anyio_backend_name) + run_application(DummyCLIComponent, backend=anyio_backend_name) assert exc.value.code == 5 @@ -174,7 +177,7 @@ async def run(self) -> int: with pytest.raises(SystemExit) as exc: with pytest.warns(UserWarning) as record: - run_application(DummyCLIComponent(), backend=anyio_backend_name) + run_application(DummyCLIComponent, backend=anyio_backend_name) assert exc.value.code == 1 assert len(record) == 1 @@ -187,7 +190,7 @@ async def run(self) -> int: with pytest.raises(SystemExit) as exc: with pytest.warns(UserWarning) as record: - run_application(DummyCLIComponent(), backend=anyio_backend_name) + run_application(DummyCLIComponent, backend=anyio_backend_name) assert exc.value.code == 1 assert len(record) == 1 @@ -199,14 +202,14 @@ async def run(self) -> NoReturn: raise Exception("blah") with raises_in_exception_group(Exception, match="blah"): - run_application(DummyCLIComponent(), backend=anyio_backend_name) + run_application(DummyCLIComponent, backend=anyio_backend_name) async def test_start_component_no_context() -> None: with pytest.raises( RuntimeError, match=r"start_component\(\) requires an active Asphalt context" ): - await start_component(DummyComponent()) + await start_component(DummyComponent) async def test_start_component_timeout() -> None: @@ -217,7 +220,7 @@ async def start(self) -> None: async with Context(): with pytest.raises(TimeoutError, match="timeout starting component"): - await start_component(StallingComponent(), timeout=0.01) + await start_component(StallingComponent, timeout=0.01) async def test_prepare() -> None: @@ -237,5 +240,5 @@ async def start(self) -> None: add_resource(foo + "bar", "bar") async with Context(): - await start_component(ParentComponent()) + await start_component(ParentComponent) assert get_resource_nowait(str, "bar") == "foobar" diff --git a/tests/test_runner.py b/tests/test_runner.py index d77e56ec..91416ba7 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -65,11 +65,10 @@ class DummyCLIApp(CLIApplicationComponent): def __init__(self, exit_code: int | None = None): super().__init__() self.exit_code = exit_code - self.teardown_callback_called = False self.exception: BaseException | None = None def teardown_callback(self, exception: BaseException | None) -> None: - self.teardown_callback_called = True + logging.getLogger(__name__).info("Teardown callback called") self.exception = exception async def start(self) -> None: @@ -96,9 +95,7 @@ def test_run_logging_config( with patch("asphalt.core._runner.basicConfig") as basicConfig, patch( "asphalt.core._runner.dictConfig" ) as dictConfig: - run_application( - DummyCLIApp(), logging=logging_config, backend=anyio_backend_name - ) + run_application(DummyCLIApp, logging=logging_config, backend=anyio_backend_name) assert basicConfig.call_count == (1 if logging_config == logging.INFO else 0) assert dictConfig.call_count == (1 if isinstance(logging_config, dict) else 0) @@ -124,11 +121,12 @@ async def get_default_total_tokens() -> float: limiter = to_thread.current_default_thread_limiter() return limiter.total_tokens - component = MaxThreadsComponent() expected_total_tokens = max_threads or anyio.run( get_default_total_tokens, backend=anyio_backend_name ) - run_application(component, max_threads=max_threads, backend=anyio_backend_name) + run_application( + MaxThreadsComponent, max_threads=max_threads, backend=anyio_backend_name + ) assert observed_total_tokens == expected_total_tokens @@ -139,18 +137,14 @@ def test_run_callbacks(caplog: LogCaptureFixture, anyio_backend_name: str) -> No """ caplog.set_level(logging.INFO) - component = DummyCLIApp() - run_application(component, backend=anyio_backend_name) + run_application(DummyCLIApp, backend=anyio_backend_name) - assert component.teardown_callback_called - records = [ - record for record in caplog.records if record.name == "asphalt.core._runner" - ] - assert len(records) == 4 - assert records[0].message == "Running in development mode" - assert records[1].message == "Starting application" - assert records[2].message == "Application started" - assert records[3].message == "Application stopped" + assert len(caplog.messages) == 5 + assert caplog.messages[0] == "Running in development mode" + assert caplog.messages[1] == "Starting application" + assert caplog.messages[2] == "Application started" + assert caplog.messages[3] == "Teardown callback called" + assert caplog.messages[4] == "Application stopped" @pytest.mark.parametrize( @@ -182,8 +176,7 @@ def test_clean_exit( """ caplog.set_level(logging.INFO) - component = ShutdownComponent(method=method) - run_application(component, backend=anyio_backend_name) + run_application(ShutdownComponent, {"method": method}, backend=anyio_backend_name) records = [ record for record in caplog.records if record.name == "asphalt.core._runner" @@ -232,9 +225,8 @@ def test_start_exception( """ caplog.set_level(logging.INFO) - component = CrashComponent(method=method) with pytest.raises(SystemExit) as exc_info: - run_application(component, backend=anyio_backend_name) + run_application(CrashComponent, {"method": method}, backend=anyio_backend_name) assert exc_info.value.code == 1 records = [ @@ -265,9 +257,13 @@ async def start(self) -> None: await get_resource(float, wait=True) caplog.set_level(logging.INFO) - component = StallingComponent(1) with pytest.raises(SystemExit) as exc_info: - run_application(component, start_timeout=0.1, backend=anyio_backend_name) + run_application( + StallingComponent, + {"level": 1}, + start_timeout=0.1, + backend=anyio_backend_name, + ) assert exc_info.value.code == 1 assert len(caplog.messages) == 4 @@ -322,16 +318,16 @@ async def start(self) -> None: def test_dict_config(caplog: LogCaptureFixture, anyio_backend_name: str) -> None: """Test that component configuration passed as a dictionary works.""" caplog.set_level(logging.INFO) - run_application(component={"type": DummyCLIApp}, backend=anyio_backend_name) + run_application( + config_or_component_class={"type": DummyCLIApp}, backend=anyio_backend_name + ) - records = [ - record for record in caplog.records if record.name == "asphalt.core._runner" - ] - assert len(records) == 4 - assert records[0].message == "Running in development mode" - assert records[1].message == "Starting application" - assert records[2].message == "Application started" - assert records[3].message == "Application stopped" + assert len(caplog.messages) == 5 + assert caplog.messages[0] == "Running in development mode" + assert caplog.messages[1] == "Starting application" + assert caplog.messages[2] == "Application started" + assert caplog.messages[3] == "Teardown callback called" + assert caplog.messages[4] == "Application stopped" def test_run_cli_application( @@ -339,15 +335,13 @@ def test_run_cli_application( ) -> None: caplog.set_level(logging.INFO) with pytest.raises(SystemExit) as exc: - run_application(DummyCLIApp(20), backend=anyio_backend_name) + run_application(DummyCLIApp, {"exit_code": 20}, backend=anyio_backend_name) assert exc.value.code == 20 - records = [ - record for record in caplog.records if record.name == "asphalt.core._runner" - ] - assert len(records) == 4 - assert records[0].message == "Running in development mode" - assert records[1].message == "Starting application" - assert records[2].message == "Application started" - assert records[3].message == "Application stopped" + assert len(caplog.messages) == 5 + assert caplog.messages[0] == "Running in development mode" + assert caplog.messages[1] == "Starting application" + assert caplog.messages[2] == "Application started" + assert caplog.messages[3] == "Teardown callback called" + assert caplog.messages[4] == "Application stopped" From 6d718f44505eefa1e940b017309a9e7118200884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 9 Jun 2024 17:40:38 +0300 Subject: [PATCH 08/11] Avoid TypeError if config_or_component_class is neither a dict or a class --- src/asphalt/core/_component.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/asphalt/core/_component.py b/src/asphalt/core/_component.py index 519daeee..3425d29e 100644 --- a/src/asphalt/core/_component.py +++ b/src/asphalt/core/_component.py @@ -246,7 +246,9 @@ async def start_component( """ if isinstance(config_or_component_class, dict): configuration = config_or_component_class - elif issubclass(config_or_component_class, Component): + elif isclass(config_or_component_class) and issubclass( + config_or_component_class, Component + ): configuration = config or {} configuration["type"] = config_or_component_class else: From 1df12d2b1baff4d3fa7957539b3b6300fcdcee36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 9 Jun 2024 23:51:13 +0300 Subject: [PATCH 09/11] Added docs about TypeError in start_component --- src/asphalt/core/_component.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/asphalt/core/_component.py b/src/asphalt/core/_component.py index 3425d29e..8e1e214a 100644 --- a/src/asphalt/core/_component.py +++ b/src/asphalt/core/_component.py @@ -242,6 +242,8 @@ async def start_component( :raises RuntimeError: if this function is called without an active :class:`Context` :raises TimeoutError: if the startup of the component hierarchy takes more than ``timeout`` seconds + :raises TypeError: if ``config_or_component_class`` is neither a dict or a + :class:`Component` subclass """ if isinstance(config_or_component_class, dict): From 1861c689e2b9d4250bbce8db1adf2a630f74bbce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 10 Jun 2024 00:31:38 +0300 Subject: [PATCH 10/11] Fixed `asphalt run` no longer working --- src/asphalt/core/_cli.py | 2 ++ tests/test_cli.py | 62 ++++++++++++++++++++-------------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/asphalt/core/_cli.py b/src/asphalt/core/_cli.py index fa758683..4acc970e 100644 --- a/src/asphalt/core/_cli.py +++ b/src/asphalt/core/_cli.py @@ -130,9 +130,11 @@ def run(configfile: Sequence[str], service: str | None, set_: list[str]) -> None config = merge_config(config, service_config) # Start the application + component = config.pop("component") backend = config.pop("backend", "asyncio") backend_options = config.pop("backend_options", {}) run_application( + component, **config, backend=backend, backend_options=backend_options, diff --git a/tests/test_cli.py b/tests/test_cli.py index b41b0ccf..702fcc68 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -50,27 +50,27 @@ def test_run( version: 1 disable_existing_loggers: false """ - args = ["test.yml"] with runner.isolated_filesystem(), patch( "asphalt.core._cli.run_application" ) as run_app: Path("test.yml").write_text(config) - result = runner.invoke(_cli.run, args) + result = runner.invoke(_cli.run, ["test.yml"]) assert result.exit_code == 0 assert run_app.call_count == 1 args, kwargs = run_app.call_args - assert len(args) == 0 - assert kwargs == { - "backend": anyio_backend_name, - "backend_options": {}, - "component": { + assert args == ( + { "type": DummyComponent, "dummyval1": "testval", "envval": "from environment", "textfileval": "Hello, World!", "binaryfileval": b"Hello, World!", }, + ) + assert kwargs == { + "backend": anyio_backend_name, + "backend_options": {}, "logging": {"version": 1, "disable_existing_loggers": False}, } @@ -146,17 +146,18 @@ def test_run_multiple_configs(runner: CliRunner) -> None: assert result.exit_code == 0 assert run_app.call_count == 1 args, kwargs = run_app.call_args - assert len(args) == 0 - assert kwargs == { - "backend": "asyncio", - "backend_options": {}, - "component": { + assert args == ( + { "type": component_class, "dummyval1": "alternate", "dummyval2": 10, "dummyval3": "bar", "dummyval4": "baz", }, + ) + assert kwargs == { + "backend": "asyncio", + "backend_options": {}, "logging": {"version": 1, "disable_existing_loggers": False}, } @@ -206,13 +207,9 @@ def test_run_service(self, runner: CliRunner, service: str) -> None: assert result.exit_code == 0 assert run_app.call_count == 1 args, kwargs = run_app.call_args - assert len(args) == 0 if service == "server": - assert kwargs == { - "backend": "asyncio", - "backend_options": {}, - "max_threads": 30, - "component": { + assert args == ( + { "type": "myproject.server.ServerComponent", "components": { "wamp": { @@ -225,14 +222,16 @@ def test_run_service(self, runner: CliRunner, service: str) -> None: "mailer": {"backend": "smtp"}, }, }, - "logging": {"version": 1, "disable_existing_loggers": False}, - } - else: + ) assert kwargs == { "backend": "asyncio", "backend_options": {}, - "max_threads": 15, - "component": { + "max_threads": 30, + "logging": {"version": 1, "disable_existing_loggers": False}, + } + else: + assert args == ( + { "type": "myproject.client.ClientComponent", "components": { "wamp": { @@ -244,6 +243,11 @@ def test_run_service(self, runner: CliRunner, service: str) -> None: } }, }, + ) + assert kwargs == { + "backend": "asyncio", + "backend_options": {}, + "max_threads": 15, "logging": {"version": 1, "disable_existing_loggers": False}, } @@ -334,11 +338,10 @@ def test_run_only_service(self, runner: CliRunner) -> None: assert result.exit_code == 0 assert run_app.call_count == 1 args, kwargs = run_app.call_args - assert len(args) == 0 + assert args == ({"type": "myproject.client.ClientComponent"},) assert kwargs == { "backend": "asyncio", "backend_options": {}, - "component": {"type": "myproject.client.ClientComponent"}, "logging": {"version": 1, "disable_existing_loggers": False}, } @@ -366,11 +369,10 @@ def test_run_default_service(self, runner: CliRunner) -> None: assert result.exit_code == 0 assert run_app.call_count == 1 args, kwargs = run_app.call_args - assert len(args) == 0 + assert args == ({"type": "myproject.server.ServerComponent"},) assert kwargs == { "backend": "asyncio", "backend_options": {}, - "component": {"type": "myproject.server.ServerComponent"}, "logging": {"version": 1, "disable_existing_loggers": False}, } @@ -401,11 +403,10 @@ def test_service_env_variable( assert result.exit_code == 0 assert run_app.call_count == 1 args, kwargs = run_app.call_args - assert len(args) == 0 + assert args == ({"type": "myproject.client.ClientComponent"},) assert kwargs == { "backend": "asyncio", "backend_options": {}, - "component": {"type": "myproject.client.ClientComponent"}, "logging": {"version": 1, "disable_existing_loggers": False}, } @@ -436,10 +437,9 @@ def test_service_env_variable_override( assert result.exit_code == 0 assert run_app.call_count == 1 args, kwargs = run_app.call_args - assert len(args) == 0 + assert args == ({"type": "myproject.server.ServerComponent"},) assert kwargs == { "backend": "asyncio", "backend_options": {}, - "component": {"type": "myproject.server.ServerComponent"}, "logging": {"version": 1, "disable_existing_loggers": False}, } From 26260c10ee0a6125ce20e37c8fac21f55e560cf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 10 Jun 2024 10:30:50 +0300 Subject: [PATCH 11/11] Removed docs changes that were never implemented --- docs/userguide/deployment.rst | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/docs/userguide/deployment.rst b/docs/userguide/deployment.rst index b691a958..5f3b09ea 100644 --- a/docs/userguide/deployment.rst +++ b/docs/userguide/deployment.rst @@ -62,16 +62,13 @@ You could then write a configuration file like this:: max_threads: 20 component: type: !!python/name:myproject.MyRootComponent - config: - data_directory: /some/file/somewhere + data_directory: /some/file/somewhere components: mailer: - config: - host: smtp.mycompany.com - ssl: true + host: smtp.mycompany.com + ssl: true sqlalchemy: - config: - url: postgresql:///mydatabase + url: postgresql:///mydatabase logging: version: 1 @@ -82,7 +79,7 @@ You could then write a configuration file like this:: formatter: generic formatters: generic: - format: "%(asctime)s:%(levelname)s:%(name)s:%(message)s" + format: "%(asctime)s:%(levelname)s:%(name)s:%(message)s" root: handlers: [console] level: INFO