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/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 9f9f68ed..5a2447c7 100644 --- a/docs/tutorials/snippets/webnotifier-app4.py +++ b/docs/tutorials/snippets/webnotifier-app4.py @@ -15,14 +15,13 @@ class ApplicationComponent(CLIApplicationComponent): - async def start(self) -> None: + def __init__(self) -> None: 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: @@ -52,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/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..e6007c8b 100644 --- a/docs/userguide/components.rst +++ b/docs/userguide/components.rst @@ -6,41 +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. - -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. +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/deployment.rst b/docs/userguide/deployment.rst index 6ddb7e43..5f3b09ea 100644 --- a/docs/userguide/deployment.rst +++ b/docs/userguide/deployment.rst @@ -49,15 +49,12 @@ 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 - - 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:: @@ -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 @@ -151,7 +148,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/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/examples/tutorial2/webnotifier/app.py b/examples/tutorial2/webnotifier/app.py index 58899771..3fca129a 100644 --- a/examples/tutorial2/webnotifier/app.py +++ b/examples/tutorial2/webnotifier/app.py @@ -1,6 +1,8 @@ """This is the root component for the Asphalt webnotifier tutorial.""" # isort: off +from __future__ import annotations + import logging from difflib import HtmlDiff @@ -13,10 +15,9 @@ class ApplicationComponent(CLIApplicationComponent): - async def start(self) -> None: + def __init__(self) -> None: self.add_component("detector", ChangeDetectorComponent) self.add_component("mailer", backend="smtp") - await super().start() @inject async def run( diff --git a/src/asphalt/core/__init__.py b/src/asphalt/core/__init__.py index 299342fd..a4f1112f 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/_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/src/asphalt/core/_component.py b/src/asphalt/core/_component.py index 1990db61..8e1e214a 100644 --- a/src/asphalt/core/_component.py +++ b/src/asphalt/core/_component.py @@ -1,13 +1,13 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -from collections import OrderedDict 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 -from typing import Any +from typing import Any, TypeVar, overload from anyio import ( CancelScope, @@ -23,64 +23,26 @@ 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.""" - __slots__ = () - - @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]]] - """ - - __slots__ = "child_components", "component_configs" - - def __init__( - self, components: dict[str, dict[str, Any] | None] | None = None - ) -> None: - self.child_components: OrderedDict[str, Component] = OrderedDict() - self.component_configs = components or {} + _child_components: dict[str, dict[str, Any]] | None = None + _component_started = False 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. - 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). @@ -94,43 +56,66 @@ 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: + raise RuntimeError( + "child components cannot be added once start_component() has been " + "called on the component" + ) + if not isinstance(alias, str) or not alias: - raise TypeError("component_alias must be a nonempty string") - if alias in self.child_components: + raise TypeError("alias must be a nonempty string") + + if type is None: + type = 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" + ) + + 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}"') - config["type"] = type or alias + self._child_components[alias] = {"type": type, **config} - # Allow the external configuration to override the constructor arguments - override_config = self.component_configs.get(alias) or {} - config = merge_config(config, override_config) + async def prepare(self) -> None: + """ + Perform any necessary initialization before starting the component. - component = component_types.create_object(**config) - self.child_components[alias] = 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: """ - 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. + Perform any necessary tasks to start the services provided by this component. - """ - for alias in self.component_configs: - if alias not in self.child_components: - self.add_component(alias) + 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. - 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})", - ) + .. warning:: Do not call this method directly; use :func:`start_component` + instead. + """ -class CLIApplicationComponent(ContainerComponent): +class CLIApplicationComponent(Component): """ Specialized subclass of :class:`.ContainerComponent` for command line tools. @@ -160,22 +145,119 @@ async def run(self) -> int | None: component_types = PluginContainer("asphalt.components", Component) -async def start_component( +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", {}) + + # 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 = 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 + + +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 := child_components_by_alias.get(path): + async with create_task_group() as tg: + 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_by_alias, + name=f"Starting {final_path} ({qualified_name(child_component)})", + ) + + await component.start() + + +@overload +async def start_component( + 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 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` :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): + configuration = config_or_component_class + elif isclass(config_or_component_class) and 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: @@ -183,28 +265,33 @@ 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 component.start() - - # Cancel the startup timeout, if any - if startup_watcher_scope: - startup_watcher_scope.cancel() + await _start_component(root_component, "", child_components_by_alias) if startup_scope.cancel_called: raise TimeoutError("timeout starting component") + # Cancel the startup timeout, if any + if startup_watcher_scope: + startup_watcher_scope.cancel() + + return root_component + async def _component_startup_watcher( startup_cancel_scope: CancelScope, @@ -263,7 +350,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 @@ -277,10 +364,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/_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/src/asphalt/core/_utils.py b/src/asphalt/core/_utils.py index 66b300f0..61bad7e6 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_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}, } diff --git a/tests/test_component.py b/tests/test_component.py index 9f48760f..cd9a5625 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,8 +13,9 @@ from asphalt.core import ( CLIApplicationComponent, Component, - ContainerComponent, Context, + add_resource, + get_resource_nowait, run_application, start_component, ) @@ -29,13 +30,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,77 +53,102 @@ 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) + components_container: dict[str, DummyComponent] = {} + + 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, + ) - assert len(container.child_components) == 1 - component = container.child_components["dummy"] - assert isinstance(component, DummyComponent) - assert component.kwargs == {"a": 1, "b": 2, "c": 3} + async with Context(): + await start_component(ContainerComponent) - 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. - - """ - 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"), - ( + 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, - 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) + async def test_add_component_during_start(self) -> None: + class BadContainerComponent(Component): + async def start(self) -> None: + self.add_component("foo", DummyComponent) - dummy = cast(DummyComponent, container.child_components["dummy"]) - assert dummy.started + async with Context(): + with pytest.raises(RuntimeError, match="child components cannot be added"): + await start_component(BadContainerComponent) class TestCLIApplicationComponent: @@ -125,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): @@ -133,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 @@ -144,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 @@ -157,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 @@ -169,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(ContainerComponent()) + await start_component(DummyComponent) async def test_start_component_timeout() -> None: @@ -187,4 +220,25 @@ 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: + 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" diff --git a/tests/test_runner.py b/tests/test_runner.py index 80fdecc9..91416ba7 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, @@ -66,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: @@ -97,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) @@ -125,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 @@ -140,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( @@ -183,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" @@ -233,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 = [ @@ -252,25 +243,27 @@ 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 + 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) 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 @@ -325,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( @@ -342,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"