Introduction

coma makes it easy to build configurable command-based programs in Python.

coma uses a template-based architecture that employs hooks to implement, tweak, replace, or extend its behavior. In this tutorial, we’ll explore coma’s default behavior. Nearly all of it results from pre-defined hooks. coma has very few baked in assumptions, so its behavior can be drastically changed with user-defined hooks. We’ll highlight these alternatives in later tutorials and examples.

Commands

For now, let’s dive in with a classic Hello World! program:

from coma import command, wake

@command
def greet():
    print("Hello World!")

if __name__ == "__main__":
    wake()

coma’s interface consists of only two functions:

@command: This decorator declares a Callable as a command. The command declaration can be tweaked via additional arguments passed to @command. We’ll explore these a little later.

wake(): This function registers all declared commands with argparse so that commands can be invoked via the command line.

Let’s do that now by running this program on the command line:

$ python main.py greet
Hello World!

In addition to functions, @command can also decorate any Python class with a no-argument run() method:

from coma import command, wake

@command
class Greet:
    def run(self):
        print("Hello World!")

if __name__ == "__main__":
    wake()

The inferred command name is, by default, the lowercase name of the decorated Callable (function or class). As with many facets of command declaration, this can be tweaked using one of the many additional parameters to @command. In this case, we will provide an explicit command name via the name parameter:

from coma import command, wake

@command(name="greet")
def an_absurdly_long_function_name_that_isnt_suitable_for_the_command_line():
    print("Hello World!")

if __name__ == "__main__":
    wake()

The @command decorator can also be called as a regular function, command(), to register a command procedurally:

from coma import command, wake

if __name__ == "__main__":
    command(name="greet", cmd=lambda: print("Hello World!"))
    wake()

Multiple Commands

coma is intended to manage multiple commands as part of building complex programs. Let’s extend our previous example:

from coma import command, wake

@command
def greet():
    print("Hello World!")

@command
def leave():
    print("Goodbye World!")

if __name__ == "__main__":
    wake()

This registers two commands, each with a different program behavior:

$ python main.py greet
Hello World!
$ python main.py leave
Goodbye World!

Mixing function-based and class-based command declarations is perfectly acceptable.

Configurations

What makes coma truly powerful is its integration with omegaconf’s extremely rich configuration management features. omegaconf’s tutorials are excellent, so we won’t explore all its features here (only the basics needed to understand its integration with coma).

At a high level, omegaconf configs are backed by either plain Python list and dict objects, or by Python dataclasses. list and dict configs are maximally flexible: They accept any objects that normal Python list and dict do. dataclasses-backed configs, on the other hand, are known as structured configs. omegaconf rigorously type validates these configs at runtime based on the underlying dataclass declaration.

In coma, it is command parameters that declare which configs a particular command requires. Let’s declare a Recipient config for our running example:

from coma import command, wake
from dataclasses import dataclass

@dataclass
class Recipient:
    entity: str = "World"

@command
def greet(recipient: Recipient):
    print(f"Hello {recipient.entity}!")

@command
def leave(recipient: Recipient):
    print(f"Goodbye {recipient.entity}!")

if __name__ == "__main__":
    wake()

Note

The @command decorator provides a rich interface for tweaking which command parameters are configs and which are regular parameters. It also enables inline config parameters. Additionally, variadic parameters (*args and **kwargs) can be configs if desired.

Invoking on the command line, we get:

$ python main.py greet
Hello World!
$ python main.py leave
Goodbye World!

Notice that the output is the same as before, because the default value of recipient.entity is World. That default value is used (unsurprisingly) by default when invoking a command. We can override this default by supplying an alternative value on the command line using the config name as a prefix (recipient), followed by the prefix delimiter (::), followed by the config attribute path (entity) specified in omegaconf’s dot-list notation format, followed by omegaconf’s value delimiter (=), followed by the new attribute value (coma):

$ python main.py greet recipient::entity=coma
Hello coma!
$ python main.py leave recipient::entity=coma
Goodbye coma!

Note

The config name prefix can be shortened or even entirely omitted if the config attribute being referred to is unambiguous. That is the case in this example, since we only have a single config. So the following are all equivalent in this example:

$ python main.py greet recipient::entity=coma
Hello coma!
$ python main.py greet r::entity=coma
Hello coma!
$ python main.py greet entity=coma
Hello coma!

See here for full details on command line overrides.

Note

If the command is a Python class, it is the __init__() method that declares which configs the command will require (not the run() method):

from coma import command, wake
from dataclasses import dataclass

@dataclass
class Recipient:
    entity: str = "World"

@command
class Greet:
    def __init__(self, recipient: Recipient):
        self.recipient = recipient

    def run(self):
        print(f"Hello {self.recipient.entity}!")

if __name__ == "__main__":
    wake()

This separation between initialization (via __init__()) and execution (via run()) is done so that stateful commands can be initialized based on config attributes, which is typically more straightforward than delaying part of the initialization until run() is called, which would be the case if the latter required config declaration.

Config Serialization

Most configs are automatically serializable, meaning they are saved to file the first time a command is invoked. By default, the file name is based on the config’s parameter name in the command declaration (config recipient is saved to recipient.yaml in our example):

$ ls
main.py
recipient.yaml
$ cat recipient.yaml
entity: World

Notice that it is the default config value that gets saved to file, not any subsequent command line overrides. Configs in coma adhere to a declaration hierarchy:

Config Declaration Hierarchy:

command line override > file (if config is serializable) > code default

As such, updating recipient.yaml changes the config attributes that are loaded on command invocation (when no command line overrides are provided). Suppose we update recipient.yaml to contain the following:

entity: coma

Invoking the commands now clearly demonstrates the declaration hierarchy:

$ python main.py greet  # No command line override. Load from file.
Hello coma!
$ python main.py leave entity=foo  # Command line override.
Goodbye foo!

Config serialization enables configs to be shared between commands. We’ve done this implicitly in the running example, since both greet and leave share recipient. This is one of coma’s most powerful features, as it allows complex programs to declare modular configs once and then share them everywhere without having repeat definitions.

However, sometimes we do want to have a separate config for each command. coma also supports this use case. Simply use unique config names across the command declarations:

from coma import command, wake
from dataclasses import dataclass

@dataclass
class Recipient:
    entity: str = "World"

@command
def greet(greet_recipient: Recipient):
    print(f"Hello {greet_recipient.entity}!")

@command
def leave(leave_recipient: Recipient):
    print(f"Goodbye {leave_recipient.entity}!")

if __name__ == "__main__":
    wake()

Now, we have two independent config files:

$ ls
main.py
greet_recipient.yaml
leave_recipient.yaml

Updating greet_recipient.yaml only affects greet. Updating leave_recipient.yaml only affects leave. For even more details on config serialization, see this tutorial and this example.

Multiple Configurations

coma enables commands to take an arbitrary number of independent configs. Multiple configs are often useful in practice to separate otherwise-large configs into smaller components, especially if only some of those components are shared between commands. Let’s declare two new configs (Salutation and Parting) in our running example, while reverting Recipient to be shared between leave and greet:

from coma import command, wake
from dataclasses import dataclass

@dataclass
class Salutation:
    phrase: str = "Hello"

@dataclass
class Parting:
    phrase: str = "Goodbye"

@dataclass
class Recipient:
    entity: str = "World"

@command
def greet(salutation: Salutation, recipient: Recipient):
    print(f"{salutation.phrase} {recipient.entity}!")

@command
def leave(parting: Parting, recipient: Recipient):
    print(f"{parting.phrase} {recipient.entity}!")

if __name__ == "__main__":
    wake()

We can invoke both commands as before. They share recipient so any changes to recipient.yaml are reflected in both commands. Changes to the other configs only affect the respective command. Command line overrides are not serialized (by default) so overrides to one command do not affect the other:

$ python main.py greet phrase=Hey entity=coma
Hey coma!
$ python main.py leave
Goodbye World!

Next Steps

🎉 You now have a solid foundation for writing Python programs with modular configurable commands using coma’s declarative interface! 🎉

coma offers many additional features, including:

  • Customizing the underlying argparse objects.

  • Adding command line arguments and flags to your program.

  • Using hooks to tweak, replace, or extend coma’s default behavior.

  • Registering shared hooks that are declared once and propagated to every command.

  • And lots more!

Read the other tutorials and usage examples to learn more.