Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactored component hierarchies and initialization #121

Merged
merged 14 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ Components
----------

.. autoclass:: Component
.. autoclass:: ContainerComponent
.. autoclass:: CLIApplicationComponent
.. autofunction:: start_component

Expand Down
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)
5 changes: 2 additions & 3 deletions docs/tutorials/snippets/webnotifier-app4.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "[email protected]", "to": "[email protected]"},
)
await super().start()

@inject
async def run(self, *, mailer: Mailer = resource()) -> None:
Expand Down Expand Up @@ -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)
13 changes: 7 additions & 6 deletions docs/tutorials/webnotifier.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
132 changes: 99 additions & 33 deletions docs/userguide/components.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 9 additions & 9 deletions docs/userguide/deployment.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,29 +49,29 @@ 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::

---
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
agronholm marked this conversation as resolved.
Show resolved Hide resolved
sqlalchemy:
url: postgresql:///mydatabase
config:
url: postgresql:///mydatabase

logging:
version: 1
Expand Down Expand Up @@ -151,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``
* ...
Expand Down
49 changes: 49 additions & 0 deletions docs/userguide/snippets/components1.py
Original file line number Diff line number Diff line change
@@ -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)
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
5 changes: 3 additions & 2 deletions examples/tutorial2/webnotifier/app.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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(
Expand Down
Loading
Loading