Skip to content

Commit

Permalink
Allow configuration through CLI (#50)
Browse files Browse the repository at this point in the history
Co-authored-by: Alex Grönholm <alex.gronholm@nextday.fi>
  • Loading branch information
davidbrochart and agronholm committed Dec 2, 2022
1 parent 39ff21a commit b651db8
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 13 deletions.
40 changes: 30 additions & 10 deletions docs/userguide/deployment.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,18 @@ Running the launcher is very straightfoward:

.. code-block:: bash
asphalt run yourconfig.yaml [your-overrides.yml...]
asphalt run yourconfig.yaml [your-overrides.yml...] [--set path.to.key=val]
Or alternatively:

python -m asphalt run yourconfig.yaml [your-overrides.yml...]
python -m asphalt run yourconfig.yaml [your-overrides.yml...] [--set path.to.key=val]

What this will do is:

#. read all the given configuration files, starting from ``yourconfig.yaml``
#. merge the configuration files' contents into a single configuration dictionary using
:func:`~asphalt.core.utils.merge_config`
#. read the command line configuration options passed with ``--set``, if any
#. merge the configuration files' contents and the command line configuration options into a single configuration dictionary using
:func:`~asphalt.core.utils.merge_config`.
#. call :func:`~asphalt.core.runner.run_application` using the configuration dictionary as keyword
arguments

Expand Down Expand Up @@ -147,8 +148,10 @@ Component configuration can be specified on several levels:
* First configuration file argument to ``asphalt run``
* Second configuration file argument to ``asphalt run``
* ...
* Command line configuration options to ``asphalt run --set``

Any options you specify on each level override or augment any options given on previous levels.
The command line configuration options have precedence over the configuration files.
To minimize the effort required to build a working configuration file for your application, it is
suggested that you pass as many of the options directly in the component initialization code and
leave only deployment specific options like API keys, access credentials and such to the
Expand All @@ -162,12 +165,29 @@ gets passed three keyword arguments:
* ``ssl=True``

The first one is provided in the root component code while the other two options come from the YAML
file. You could also override the mailer backend in the configuration file if you wanted. The same
effect can be achieved programmatically by supplying the override configuration to the container
component via its ``components`` constructor argument. This is very useful when writing tests
against your application. For example, you might want to use the ``mock`` mailer in your test suite
configuration to test that the application correctly sends out emails (and to prevent them from
actually being sent to recipients!).
file. You could also override the mailer backend in the configuration file if you wanted, or at the
command line (with the configuration file saved as ``config.yaml``):

.. code-block:: bash
asphalt run config.yaml --set component.components.mailer.backend=sendmail
.. note::
Note that if you want a ``.`` to be treated as part of an identifier, and not as a separator,
you need to escape it at the command line with ``\``. For instance, in both commands:

.. code-block:: bash
asphalt run config.yaml --set "logging.loggers.asphalt\.templating.level=DEBUG"
asphalt run config.yaml --set logging.loggers.asphalt\\.templating.level=DEBUG
The logging level for the ``asphalt.templating`` logger will be set to ``DEBUG``.

The same effect can be achieved programmatically by supplying the override configuration to the
container component via its ``components`` constructor argument. This is very useful when writing
tests against your application. For example, you might want to use the ``mock`` mailer in your test
suite configuration to test that the application correctly sends out emails (and to prevent them
from actually being sent to recipients!).

There is another neat trick that lets you easily modify a specific key in the configuration.
By using dotted notation in a configuration key, you can target a specific key arbitrarily deep in
Expand Down
41 changes: 39 additions & 2 deletions src/asphalt/core/cli.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import os
import re
from collections.abc import Mapping
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional

import click
from ruamel.yaml import YAML, ScalarNode
Expand Down Expand Up @@ -52,7 +54,20 @@ def main() -> None:
type=str,
help="service to run (if the configuration file contains multiple services)",
)
def run(configfile, unsafe: bool, loop: Optional[str], service: Optional[str]) -> None:
@click.option(
"--set",
"set_",
multiple=True,
type=str,
help="set configuration",
)
def run(
configfile,
unsafe: bool,
loop: Optional[str],
service: Optional[str],
set_: List[str],
) -> None:
yaml = YAML(typ="unsafe" if unsafe else "safe")
yaml.constructor.add_constructor("!Env", env_constructor)
yaml.constructor.add_constructor("!TextFile", text_file_constructor)
Expand All @@ -67,6 +82,28 @@ def run(configfile, unsafe: bool, loop: Optional[str], service: Optional[str]) -
), "the document root element must be a dictionary"
config = merge_config(config, config_data)

# Override config options
for override in set_:
if "=" not in override:
raise click.ClickException(
f"Configuration must be set with '=', got: {override}"
)

key, value = override.split("=", 1)
parsed_value = yaml.load(value)
keys = [k.replace(r"\.", ".") for k in re.split(r"(?<!\\)\.", key)]
section = config
for i, part_key in enumerate(keys[:-1]):
section = section.setdefault(part_key, {})
if not isinstance(section, Mapping):
path = " ⟶ ".join(x for x in keys[: i + 1])
raise click.ClickException(
f"Cannot apply override for {key!r}: value at {path} is not "
f"a mapping, but {qualified_name(section)}"
)

section[keys[-1]] = parsed_value

# Override the event loop policy if specified
if loop:
config["event_loop_policy"] = loop
Expand Down
47 changes: 46 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,38 @@ def test_run(
}


def test_run_bad_override(runner: CliRunner) -> None:
config = """\
component:
type: does.not.exist:Component
"""
with runner.isolated_filesystem():
Path("test.yml").write_text(config)
result = runner.invoke(cli.run, ["test.yml", "--set", "foobar"])
assert result.exit_code == 1
assert result.stdout == (
"Error: Configuration must be set with '=', got: foobar\n"
)


def test_run_bad_path(runner: CliRunner) -> None:
config = """\
component:
type: does.not.exist:Component
listvalue: []
"""
with runner.isolated_filesystem():
Path("test.yml").write_text(config)
result = runner.invoke(
cli.run, ["test.yml", "--set", "component.listvalue.foo=1"]
)
assert result.exit_code == 1
assert result.stdout == (
"Error: Cannot apply override for 'component.listvalue.foo': value at "
"component ⟶ listvalue is not a mapping, but list\n"
)


def test_run_multiple_configs(runner: CliRunner) -> None:
component_class = "{0.__module__}:{0.__name__}".format(DummyComponent)
config1 = """\
Expand All @@ -106,14 +138,25 @@ def test_run_multiple_configs(runner: CliRunner) -> None:
component:
dummyval1: alternate
dummyval2: 10
dummyval3: foo
"""

with runner.isolated_filesystem(), patch(
"asphalt.core.cli.run_application"
) as run_app:
Path("conf1.yml").write_text(config1)
Path("conf2.yml").write_text(config2)
result = runner.invoke(cli.run, ["conf1.yml", "conf2.yml"])
result = runner.invoke(
cli.run,
[
"conf1.yml",
"conf2.yml",
"--set",
"component.dummyval3=bar",
"--set",
"component.dummyval4=baz",
],
)

assert result.exit_code == 0
assert run_app.call_count == 1
Expand All @@ -124,6 +167,8 @@ def test_run_multiple_configs(runner: CliRunner) -> None:
"type": component_class,
"dummyval1": "alternate",
"dummyval2": 10,
"dummyval3": "bar",
"dummyval4": "baz",
},
"logging": {"version": 1, "disable_existing_loggers": False},
}
Expand Down

0 comments on commit b651db8

Please sign in to comment.