Scenario as a plugin¶
Let’s create a simple scenario plugin that list flavors.
Creation¶
Inherit a class for your plugin from the base OpenStackScenario class and implement a scenario method inside it. In our scenario, we’ll first list flavors as an ordinary user, and then repeat the same using admin clients:
from rally.task import atomic
from rally.task import validation
from rally_openstack.common import consts
from rally_openstack.task import scenario
@validation.add("required_services", services=[consts.Service.NOVA])
@validation.add("required_platform", platform="openstack", users=True)
@scenario.configure(name="ScenarioPlugin.list_flavors_useless",
platform="openstack")
class ListFlavors(scenario.OpenStackScenario):
"""Sample plugin which lists flavors."""
@atomic.action_timer("list_flavors")
def _list_flavors(self):
"""Sample of usage clients - list flavors
You can use self.context, self.admin_clients and self.clients
which are initialized on scenario instance creation"""
self.clients("nova").flavors.list()
@atomic.action_timer("list_flavors_as_admin")
def _list_flavors_as_admin(self):
"""The same with admin clients"""
self.admin_clients("nova").flavors.list()
def run(self):
"""List flavors."""
self._list_flavors()
self._list_flavors_as_admin()
Validating and documenting arguments with type annotations¶
The run() method’s parameters can be annotated with regular Python type
hints. Rally derives a JSON Schema from these annotations and uses it to:
validate the input
argsprovided in a task (before the task starts)display each parameter’s type in
rally plugin showand the Plugins Reference.
Annotating arguments is the recommended way to declare their types and constraints. Un-annotated scenarios keep working unchanged.
Rally understands the plain types (int, float, str, bool,
list, dict), typing.Optional / | None, enum.Enum and
typing.Literal for a fixed set of values, parameterized containers such as
list[str] or dict[str, int] (whose element and value types are also
checked), and multi-type unions like int | dict[str, int] or
bool | str | None.
To constrain the value itself, annotate it with scenario.Field:
import typing as t
from rally.task import scenario
@scenario.configure(name="ScenarioPlugin.boot_servers")
class BootServers(scenario.Scenario):
def run(
self,
count: t.Annotated[int, scenario.Field(ge=1, le=100)] = 1,
flavor: str = "m1.small",
network: t.Literal["public", "private"] = "private",
description: t.Optional[str] = None,
) -> None:
"""Boot a number of servers.
:param count: how many servers to boot
:param flavor: flavor name to boot from
:param network: which network to attach
:param description: optional free-form description
"""
...
An argument that is not annotated, or whose type Rally cannot map, accepts any value and is left unvalidated.
A structured (dict) argument can be described with a TypedDict, whose
fields become individually typed properties:
import typing_extensions as te
class BootSpec(te.TypedDict, closed=True):
name: str # required
count: te.NotRequired[int] # optional
admin_pass: te.NotRequired[te.Never] # forbidden
def run(self, spec: BootSpec) -> None:
...
Two independent axes control the object schema:
required keys:
total=False(or a per-fieldRequired/NotRequired) marks fields optional; the rest are required.extra keys: extra keys are allowed by default.
closed=True(PEP 728) forbids any key that is not declared, and a field typedNotRequired[Never]forbids that specific key even when the TypedDict is open.
Note
mypy 2.x may flag closed= as an unexpected argument; it works at runtime
and is honored by Rally. Add # type: ignore[call-arg] if that check is
enforced in your project.
When a scenario simply forwards a dict of keyword arguments to another
callable, there is no need to restate that callable’s signature as a
TypedDict.
Annotate the argument with scenario.ArgsOf and Rally copies the schema from
the callable’s parameters (each becomes a property, one without a default is
required, extra keys are allowed only if the callable accepts **kwargs):
import typing as t
# the plain helper this scenario forwards its keyword arguments to
def create_widget(name: str, size: int = 1, color: str = "red") -> None:
...
def run(
self,
widget_args: t.Annotated[
dict[str, t.Any],
scenario.ArgsOf(create_widget, ignore=("name",)),
],
) -> None:
create_widget(name="widget-1", **widget_args)
The plain dict[str, t.Any] base keeps linters and mypy happy; ignore
drops parameters the scenario fills in itself (here name).
Some inputs cannot be used as raw values; they must first be transformed or
discovered (a file path read into its contents, an image name resolved to an
id). Rally does this through a pluggable pre-processing step called a resource
type; bind an argument to one inline with types.Convert(...):
import typing as t
from rally.task import types
def run(
self,
image: t.Annotated[str, types.Convert("glance_image")],
) -> None:
...
The value is then validated against the schema derived from the resource
type’s resource_spec annotation (the schema of the specification), not the
run() annotation. See Resource type as a plugin for how resource
types work, the available types, and the @types.convert decorator form.
Usage¶
You can refer to your plugin scenario in the task input files in the same way as any other scenarios:
{
"version": 2,
"title": "Using a custom scenario plugin",
"subtasks": [
{
"title": "List flavors",
"scenario": {
"ScenarioPlugin.list_flavors": {}
},
"runner": {
"serial": {
"times": 5
}
},
"contexts": {
"create_flavor": {
"ram": 512
}
}
}
]
}
This configuration file uses the “create_flavor” context which we created in Context as a plugin.