Skip to content

Commit

Permalink
Refactored run_component() to (only) accept either a dict or a compon…
Browse files Browse the repository at this point in the history
…ent class
  • Loading branch information
agronholm committed Jun 9, 2024
1 parent 7491538 commit 851dc3f
Show file tree
Hide file tree
Showing 13 changed files with 311 additions and 165 deletions.
3 changes: 1 addition & 2 deletions docs/tutorials/snippets/echo1.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,4 @@ async def start(self) -> None:


if __name__ == "__main__":
component = ServerComponent()
run_application(component)
run_application(ServerComponent)
2 changes: 1 addition & 1 deletion docs/tutorials/snippets/webnotifier-app1.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ async def run(self) -> None:


if __name__ == "__main__":
run_application(ApplicationComponent(), logging=logging.DEBUG)
run_application(ApplicationComponent, logging=logging.DEBUG)
2 changes: 1 addition & 1 deletion docs/tutorials/snippets/webnotifier-app2.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ async def run(self) -> None:


if __name__ == "__main__":
run_application(ApplicationComponent(), logging=logging.DEBUG)
run_application(ApplicationComponent, logging=logging.DEBUG)
2 changes: 1 addition & 1 deletion docs/tutorials/snippets/webnotifier-app3.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ async def run(self) -> None:


if __name__ == "__main__":
run_application(ApplicationComponent(), logging=logging.DEBUG)
run_application(ApplicationComponent, logging=logging.DEBUG)
2 changes: 1 addition & 1 deletion docs/tutorials/snippets/webnotifier-app4.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
130 changes: 99 additions & 31 deletions docs/userguide/components.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 4 additions & 5 deletions examples/tutorial1/echo/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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]})
3 changes: 1 addition & 2 deletions examples/tutorial1/echo/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,4 @@ async def start(self) -> None:


if __name__ == "__main__":
component = ServerComponent()
run_application(component)
run_application(ServerComponent)
8 changes: 3 additions & 5 deletions examples/tutorial1/tests/test_client_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading

0 comments on commit 851dc3f

Please sign in to comment.