Skip to content

Commit

Permalink
Fixed a number of smaller issues
Browse files Browse the repository at this point in the history
* Fixed `mycomponent:` (without any config) raising an exception
* Fixed obsolete/broken references in the docs
* Made the types between the runner and `start_component()` more consistent
  • Loading branch information
agronholm committed Jun 14, 2024
1 parent c480680 commit e07d5bf
Show file tree
Hide file tree
Showing 9 changed files with 67 additions and 90 deletions.
2 changes: 1 addition & 1 deletion docs/userguide/testing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ functions in the module to be run via AnyIO's pytest plugin.
The next item in the module is the ``server`` asynchronous generator fixture. Fixtures
like these are run by AnyIO's pytest plugin in their respective tasks, making the
practice of straddling a :class:`Context` on a ``yield`` safe. This would normally be
bad, as the context contains a :class:`~anyio.TaskGroup` which usually should not be
bad, as the context contains a :class:`~anyio.abc.TaskGroup` which usually should not be
used together with ``yield``, unless it's carefully managed like it is here.

The actual test function, ``test_client_and_server()`` first declares a dependency
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,5 @@ package = editable
[testenv:docs]
extras = doc
commands = sphinx-build -W docs build/sphinx {posargs}
commands = sphinx-build -n docs build/sphinx {posargs}
"""
11 changes: 9 additions & 2 deletions src/asphalt/core/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,19 @@ def run(configfile: Sequence[str], service: str | None, set_: list[str]) -> None
# Merge the service-level configuration with the top level one
config = merge_config(config, service_config)

# Extract the root component type
try:
root_component = config.pop("component")
except KeyError as exc:
raise click.ClickException(
"Service configuration is missing the 'component' key"
) from exc

# Start the application
component = config.pop("component")
backend = config.pop("backend", "asyncio")
backend_options = config.pop("backend_options", {})
run_application(
component,
root_component,
**config,
backend=backend,
backend_options=backend_options,
Expand Down
52 changes: 27 additions & 25 deletions src/asphalt/core/_component.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from abc import ABCMeta, abstractmethod
from collections.abc import Coroutine
from collections.abc import Coroutine, MutableMapping
from dataclasses import dataclass, field
from inspect import isclass
from logging import getLogger
Expand Down Expand Up @@ -117,7 +117,7 @@ async def start(self) -> None:

class CLIApplicationComponent(Component):
"""
Specialized subclass of :class:`.ContainerComponent` for command line tools.
Specialized :class:`.Component` subclass for command line tools.
Command line tools and similar applications should use this as their root component
and implement their main code in the :meth:`run` method.
Expand Down Expand Up @@ -146,10 +146,15 @@ async def run(self) -> int | None:


def _init_component(
config: dict[str, Any],
config: object,
path: str,
child_components_by_alias: dict[str, dict[str, Component]],
) -> Component:
if not isinstance(config, MutableMapping):
raise TypeError(
f"{path}: config must be a mutable mapping, not {qualified_name(config)}"
)

# Separate the child components from the config
child_components_config = config.pop("components", {})

Expand All @@ -173,10 +178,13 @@ def _init_component(
# Create the child components
child_components = child_components_by_alias[path] = {}
for alias, child_config in child_components_config.items():
if child_config is None:
child_config = {}

# If the type was specified only via an alias, use that as a type
if "type" not in child_config:
if isinstance(child_config, MutableMapping) and "type" not in child_config:
# Use the first part of the alias as type, partitioned by "/"
child_config.setdefault("type", alias.split("/")[0])
child_config["type"] = alias.split("/")[0]

final_path = f"{path}.{alias}" if path else alias

Expand Down Expand Up @@ -217,7 +225,7 @@ async def _start_component(

@overload
async def start_component(
config_or_component_class: type[TComponent],
component_class: type[TComponent],
config: dict[str, Any] | None = ...,
*,
timeout: float | None = ...,
Expand All @@ -226,54 +234,48 @@ async def start_component(

@overload
async def start_component(
config_or_component_class: dict[str, Any],
component_class: str,
config: dict[str, Any] | None = ...,
*,
timeout: float | None = ...,
) -> Component: ...


async def start_component(
config_or_component_class: type[Component] | dict[str, Any],
component_class: type[Component] | str,
config: dict[str, Any] | None = None,
*,
timeout: float | None = 20,
) -> Component:
"""
Start a component and its 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 component_class: the root component class, an entry point name in the
``asphalt.components`` namespace or a ``modulename:varname`` reference
:param config: configuration for the root component (and its child components)
: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
:raises TypeError: if ``component_class`` is neither a :class:`Component` subclass
or a string
:return: the root component instance
"""
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:
raise RuntimeError(
"start_component() requires an active Asphalt context"
) from None

if config is None:
config = {}

config.setdefault("type", component_class)
child_components_by_alias: dict[str, dict[str, Component]] = {}
root_component = _init_component(configuration, "", child_components_by_alias)
root_component = _init_component(config, "", child_components_by_alias)

with CancelScope() as startup_scope:
startup_watcher_scope: CancelScope | None = None
Expand Down
2 changes: 1 addition & 1 deletion src/asphalt/core/_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -905,7 +905,7 @@ def inject(func: Callable[P, Any]) -> Callable[P, Any]:
Parameters with dependencies need to be annotated and have :func:`resource` as the
default value. When the wrapped function is called, values for such parameters will
be automatically filled in by calling :func:`require_resource` using the parameter's
be automatically filled in by calling :func:`get_resource` using the parameter's
type annotation and the resource name passed to :func:`resource` (or ``"default"``)
as the arguments.
Expand Down
52 changes: 8 additions & 44 deletions src/asphalt/core/_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from functools import partial
from logging import INFO, Logger, basicConfig, getLogger
from logging.config import dictConfig
from typing import Any, overload
from typing import Any
from warnings import warn

import anyio
Expand Down Expand Up @@ -46,7 +46,7 @@ async def handle_signals(


async def _run_application_async(
config_or_component_class: type[Component] | dict[str, Any],
component_class: type[Component] | str,
config: dict[str, Any] | None,
logger: Logger,
max_threads: int | None,
Expand All @@ -71,7 +71,7 @@ async def _run_application_async(

try:
component = await start_component(
config_or_component_class, config, timeout=start_timeout
component_class, config, timeout=start_timeout
)
except (get_cancelled_exc_class(), TimeoutError):
# This happens when a signal handler cancels the startup or
Expand Down Expand Up @@ -105,45 +105,8 @@ async def _run_application_async(
logger.info("Application stopped")


@overload
def run_application(
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],
component_class: type[Component] | str,
config: dict[str, Any] | None = None,
*,
backend: str = "asyncio",
Expand Down Expand Up @@ -173,8 +136,9 @@ def run_application(
is set to the value of ``max_threads`` or, if omitted, the default value of
:class:`~concurrent.futures.ThreadPoolExecutor`.
: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 component_class: the root component class, an entry point name in the
``asphalt.components`` namespace or a ``modulename:varname`` reference
:param config: configuration options for the root component
: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)
Expand Down Expand Up @@ -203,7 +167,7 @@ def run_application(

if exit_code := anyio.run(
_run_application_async,
config_or_component_class,
component_class,
config,
logger,
max_threads,
Expand Down
14 changes: 14 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ def test_run_bad_override(runner: CliRunner) -> None:
)


def test_run_missing_root_component_type(runner: CliRunner) -> None:
config = """\
services:
default:
"""
with runner.isolated_filesystem():
Path("test.yml").write_text(config)
result = runner.invoke(_cli.run, ["test.yml"])
assert result.exit_code == 1
assert result.stdout == (
"Error: Service configuration is missing the 'component' key\n"
)


def test_run_bad_path(runner: CliRunner) -> None:
config = """\
component:
Expand Down
7 changes: 6 additions & 1 deletion tests/test_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,12 @@ async def test_child_components_from_config(self) -> None:
async with Context():
await start_component(
Component,
{"components": {"dummy": {"alias": "dummy", "container": container}}},
{
"components": {
"dummy": {"alias": "dummy", "container": container},
"dummy/2": None,
}
},
)

assert isinstance(container["dummy"], DummyComponent)
Expand Down
15 changes: 0 additions & 15 deletions tests/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,21 +315,6 @@ 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(
config_or_component_class={"type": DummyCLIApp}, backend=anyio_backend_name
)

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(
caplog: LogCaptureFixture, anyio_backend_name: str
) -> None:
Expand Down

0 comments on commit e07d5bf

Please sign in to comment.