Update core code for plugins with type hints

Signed-off-by: Andriy Kurilin <andr.kurilin@gmail.com>
Change-Id: I1531c90c753b3f08678e96583a225718743ff320
This commit is contained in:
Andriy Kurilin
2025-07-28 18:56:04 +02:00
parent 765d903321
commit 4b2ef81da8
8 changed files with 217 additions and 124 deletions

View File

@@ -295,33 +295,10 @@ disable_error_code = ["no-untyped-def"]
module = "rally.common.opts"
disable_error_code = ["no-untyped-def", "var-annotated"]
[[tool.mypy.overrides]]
module = "rally.common.plugin.discover"
disable_error_code = ["arg-type", "call-arg", "no-untyped-def", "union-attr"]
[[tool.mypy.overrides]]
module = "rally.common.plugin.info"
disable_error_code = ["attr-defined", "no-untyped-def"]
[[tool.mypy.overrides]]
module = "rally.common.plugin.meta"
disable_error_code = ["assignment", "attr-defined", "no-untyped-def", "var-annotated"]
[[tool.mypy.overrides]]
module = "rally.common.plugin.plugin"
disable_error_code = ["no-untyped-def", "str-format"]
[[tool.mypy.overrides]]
module = "rally.common.streaming_algorithms"
disable_error_code = ["no-untyped-def"]
[[tool.mypy.overrides]]
module = "rally.common.validation"
disable_error_code = ["attr-defined", "no-untyped-def"]
[[tool.mypy.overrides]]
module = "rally.env.env_mgr"
disable_error_code = ["attr-defined", "index", "no-untyped-def", "var-annotated"]

View File

@@ -13,6 +13,8 @@
# License for the specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import importlib
import importlib.metadata
import importlib.util
@@ -24,10 +26,17 @@ import typing as t
import rally
from rally.common import logging
if t.TYPE_CHECKING: # pragma: no cover
import types
P = t.TypeVar("P")
LOG = logging.getLogger(__name__)
def itersubclasses(cls, seen=None):
def itersubclasses(
cls: type[P], seen: set[type[P]] | None = None
) -> t.Generator[type[P], None, None]:
"""Generator over all subclasses of a given class in depth first order.
NOTE: Use 'seen' to exclude cls which was reduplicated found, because
@@ -38,16 +47,15 @@ def itersubclasses(cls, seen=None):
try:
subs = cls.__subclasses__()
except TypeError: # fails only when cls is type
subs = cls.__subclasses__(cls)
subs = cls.__subclasses__(cls) # type: ignore[call-arg]
for sub in subs:
if sub not in seen:
seen.add(sub)
yield sub
for sub in itersubclasses(sub, seen):
yield sub
yield from itersubclasses(sub, seen)
def import_modules_from_package(package):
def import_modules_from_package(package: str) -> None:
"""Import modules from package and append into sys.modules
:param package: Full package name. For example: rally.plugins.openstack
@@ -64,16 +72,18 @@ def import_modules_from_package(package):
sys.modules[module_name] = importlib.import_module(module_name)
def iter_entry_points(): # pragma: no cover
def iter_entry_points() -> t.Any: # pragma: no cover
try:
# Python 3.10+
return importlib.metadata.entry_points(group="rally_plugins")
return importlib.metadata.entry_points(
group="rally_plugins"
) # type: ignore[call-arg]
except TypeError:
# Python 3.8-3.9
return importlib.metadata.entry_points().get("rally_plugins", [])
def find_packages_by_entry_point():
def find_packages_by_entry_point() -> list[dict[str, t.Any]]:
"""Find all packages with rally_plugins entry-point"""
packages = {}
@@ -97,7 +107,9 @@ def find_packages_by_entry_point():
return list(packages.values())
def import_modules_by_entry_point(_packages: t.Union[list, None] = None):
def import_modules_by_entry_point(
_packages: list[dict[str, t.Any]] | None = None
) -> list[dict[str, t.Any]]:
"""Import plugins by entry-point 'rally_plugins'."""
if _packages is not None:
loaded_packages = _packages
@@ -132,10 +144,10 @@ def import_modules_by_entry_point(_packages: t.Union[list, None] = None):
return loaded_packages
_loaded_modules = []
_loaded_modules: list[types.ModuleType] = []
def load_plugins(dir_or_file, depth=0):
def load_plugins(dir_or_file: str, depth: int = 0) -> None:
if os.path.isdir(dir_or_file):
directory = dir_or_file
LOG.info("Loading plugins from directories %s/*" %
@@ -158,6 +170,8 @@ def load_plugins(dir_or_file, depth=0):
try:
spec = importlib.util.spec_from_file_location(
module_name, plugin_file)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load spec for {plugin_file}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)

View File

@@ -13,8 +13,14 @@
# License for the specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import re
import sys
import typing as t
if t.TYPE_CHECKING:
from rally.common.plugin import plugin
PARAM_OR_RETURNS_REGEX = re.compile(":(?:param|returns)")
RETURNS_REGEX = re.compile(":returns: (?P<doc>.*)", re.S)
@@ -22,7 +28,7 @@ PARAM_REGEX = re.compile(r":param (?P<name>[\*\w]+): (?P<doc>.*?)"
r"(?:(?=:param)|(?=:return)|(?=:raises)|\Z)", re.S)
def trim(docstring):
def trim(docstring: str) -> str:
"""trim function from PEP-257"""
if not docstring:
return ""
@@ -56,11 +62,25 @@ def trim(docstring):
return "\n".join(trimmed)
def reindent(string):
def reindent(string: str) -> str:
return "\n".join(line.strip() for line in string.strip().split("\n"))
def parse_docstring(docstring):
class _ParamInfo(t.TypedDict):
"""Type for parameter information in docstring parsing."""
name: str
doc: str
class _DocstringInfo(t.TypedDict):
"""Type for parsed docstring information."""
short_description: str
long_description: str
params: list[_ParamInfo]
returns: str
def parse_docstring(docstring: str | None) -> _DocstringInfo:
"""Parse the docstring into its components.
:returns: a dictionary of form
@@ -73,7 +93,7 @@ def parse_docstring(docstring):
"""
short_description = long_description = returns = ""
params = []
params: list[_ParamInfo] = []
if docstring:
docstring = trim(docstring.lstrip("\n"))
@@ -94,7 +114,7 @@ def parse_docstring(docstring):
if params_returns_desc:
params = [
{"name": name, "doc": trim(doc)}
_ParamInfo(name=name, doc=trim(doc))
for name, doc in PARAM_REGEX.findall(params_returns_desc)
]
@@ -110,10 +130,22 @@ def parse_docstring(docstring):
}
class InfoMixin(object):
class _PluginInfo(t.TypedDict):
"""Type for plugin information returned by get_info method."""
name: str
platform: str
module: str
title: str
description: str
parameters: list[_ParamInfo]
schema: str | None
returns: str
class InfoMixin:
@classmethod
def _get_doc(cls):
def _get_doc(cls) -> str | None:
"""Return documentary of class
By default it returns docstring of class, but it can be overridden
@@ -122,7 +154,9 @@ class InfoMixin(object):
return cls.__doc__
@classmethod
def get_info(cls):
def get_info( # type: ignore[misc]
cls: type[plugin.Plugin]
) -> _PluginInfo:
doc = parse_docstring(cls._get_doc())
return {

View File

@@ -13,10 +13,13 @@
# License for the specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import copy
import typing as t
class MetaMixin(object):
class MetaMixin:
"""Safe way to store meta information related to class object.
Allows to store information in class object instead of the instance.
@@ -61,20 +64,21 @@ class MetaMixin(object):
>>> assert B._meta_get("a") == 20
"""
_default_meta = (None, {})
_default_meta: tuple[type[MetaMixin] | None, dict[str, t.Any]] = (None, {})
_meta: dict[str, t.Any] # Dynamically created by _meta_init()
@classmethod
def _meta_init(cls):
def _meta_init(cls) -> None:
"""Initialize meta for this class."""
cls._meta = copy.deepcopy(cls._default_meta[1])
@classmethod
def _meta_clear(cls):
def _meta_clear(cls) -> None:
cls._meta.clear() # NOTE(boris-42): make sure that meta is deleted
delattr(cls, "_meta")
@classmethod
def _meta_is_inited(cls, raise_exc=True):
def _meta_is_inited(cls, raise_exc: bool = True) -> bool:
"""Check if meta is initialized.
It means that this class has own cls._meta object (not pointer
@@ -89,25 +93,25 @@ class MetaMixin(object):
return True
@classmethod
def _meta_get(cls, key, default=None):
def _meta_get(cls, key: str, default: t.Any = None) -> t.Any:
"""Get value corresponding to key in meta data."""
cls._meta_is_inited()
return cls._meta.get(key, default)
@classmethod
def _meta_set(cls, key, value):
def _meta_set(cls, key: str, value: t.Any) -> None:
"""Set value for key in meta."""
cls._meta_is_inited()
cls._meta[key] = value
@classmethod
def _meta_setdefault(cls, key, value):
def _meta_setdefault(cls, key: str, value: t.Any) -> None:
"""Set default value for key in meta."""
cls._meta_is_inited()
cls._meta.setdefault(key, value)
@classmethod
def _default_meta_init(cls, inherit=True):
def _default_meta_init(cls, inherit: bool = True) -> None:
"""Initialize default meta.
Default Meta is used to change the behavior of _meta_init() method
@@ -121,7 +125,7 @@ class MetaMixin(object):
cls._default_meta = (cls, {})
@classmethod
def _default_meta_set(cls, key, value):
def _default_meta_set(cls, key: str, value: t.Any) -> None:
if cls is not cls._default_meta[0]:
raise ReferenceError(
"Trying to update default meta from children class.")
@@ -129,11 +133,11 @@ class MetaMixin(object):
cls._default_meta[1][key] = value
@classmethod
def _default_meta_get(cls, key, default=None):
def _default_meta_get(cls, key: str, default: t.Any = None) -> t.Any:
return cls._default_meta[1].get(key, default)
@classmethod
def _default_meta_setdefault(cls, key, value):
def _default_meta_setdefault(cls, key: str, value: t.Any) -> None:
if cls is not cls._default_meta[0]:
raise ReferenceError(
"Trying to update default meta from children class.")

View File

@@ -13,7 +13,10 @@
# License for the specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import sys
import typing as t
from rally.common.plugin import discover
from rally.common.plugin import info
@@ -21,7 +24,17 @@ from rally.common.plugin import meta
from rally import exceptions
def base():
if t.TYPE_CHECKING: # pragma: no cover
P = t.TypeVar("P", bound="Plugin")
class DeprecationInfo(t.TypedDict):
"""Structure for plugin deprecation information."""
reason: str
rally_version: str
def base() -> t.Callable[[type[P]], type[P]]:
"""Mark Plugin as a base.
Base Plugins are used to have better organization of plugins providing
@@ -34,7 +47,7 @@ def base():
Plugin bases by default initialize _default_meta
"""
def wrapper(cls):
def wrapper(cls: type[P]) -> type[P]:
if not issubclass(cls, Plugin):
raise exceptions.RallyException(
"Plugin's Base can be only a subclass of Plugin class.")
@@ -52,7 +65,9 @@ def base():
return wrapper
def configure(name, platform="default", hidden=False):
def configure(
name: str, platform: str = "default", hidden: bool = False
) -> t.Callable[[type[P]], type[P]]:
"""Use this decorator to configure plugin's attributes.
Plugin is not discoverable until configure() is performed.
@@ -63,14 +78,14 @@ def configure(name, platform="default", hidden=False):
loaded only explicitly
"""
def decorator(plugin):
def decorator(plugin: type[P]) -> type[P]:
plugin_id = "%s.%s" % (plugin.__module__, plugin.__name__)
if not name:
raise ValueError(
"The name of the plugin %s cannot be empty." % plugin_id)
f"The name of the plugin {plugin_id} cannot be empty.")
if "@" in name:
raise ValueError(
"The name of the plugin cannot contain @ symbol" % plugin_id)
f"The name of the plugin {plugin_id} cannot contain @ symbol")
plugin._meta_init()
try:
@@ -93,7 +108,7 @@ def configure(name, platform="default", hidden=False):
return decorator
def default_meta(inherit=True):
def default_meta(inherit: bool = True) -> t.Callable[[type[P]], type[P]]:
"""Initialize default meta for particular plugin.
Default Meta is inherited by all children comparing to Meta which is unique
@@ -102,24 +117,26 @@ def default_meta(inherit=True):
:param inherit: Whatever to copy parents default meta
"""
def decorator(plugin):
def decorator(plugin: type[P]) -> type[P]:
plugin._default_meta_init(inherit)
return plugin
return decorator
def deprecated(reason, rally_version):
def deprecated(
reason: str, rally_version: str
) -> t.Callable[[type[P]], type[P]]:
"""Mark plugin as deprecated.
:param reason: Message that describes reason of plugin deprecation
:param rally_version: Deprecated since this version of Rally
"""
def decorator(plugin):
plugin._meta_set("deprecated", {
"reason": reason,
"rally_version": rally_version
})
def decorator(plugin: type[P]) -> type[P]:
plugin._meta_set("deprecated", DeprecationInfo(
reason=reason,
rally_version=rally_version
))
return plugin
return decorator
@@ -128,17 +145,24 @@ def deprecated(reason, rally_version):
class Plugin(meta.MetaMixin, info.InfoMixin):
"""Base class for all Plugins in Rally."""
base_ref: t.ClassVar[type[Plugin]] # Dynamically set by @base() decorator
@classmethod
def unregister(cls):
def unregister(cls) -> None:
"""Removes all plugin meta information and makes it undiscoverable."""
cls._meta_clear()
@classmethod
def _get_base(cls):
def _get_base(cls) -> type[Plugin]:
return getattr(cls, "base_ref", Plugin)
@classmethod
def get(cls, name, platform=None, allow_hidden=False):
def get(
cls: type[P],
name: str,
platform: str | None = None,
allow_hidden: bool = False
) -> type[P]:
"""Return plugin by its name for specified platform.
:param name: Plugin's name or fullname
@@ -172,7 +196,12 @@ class Plugin(meta.MetaMixin, info.InfoMixin):
plugins=", ".join(p.get_fullname() for p in results))
@classmethod
def get_all(cls, platform=None, allow_hidden=False, name=None):
def get_all(
cls: type[P],
platform: str | None = None,
allow_hidden: bool = False,
name: str | None = None,
) -> list[type[P]]:
"""Return all subclass plugins of plugin.
All plugins that are not configured will be ignored.
@@ -198,26 +227,26 @@ class Plugin(meta.MetaMixin, info.InfoMixin):
return plugins
@classmethod
def get_name(cls):
def get_name(cls) -> str:
"""Return plugin's name."""
return cls._meta_get("name")
@classmethod
def get_platform(cls):
def get_platform(cls) -> str:
""""Return plugin's platform name."""
return cls._meta_get("platform")
@classmethod
def get_fullname(cls):
def get_fullname(cls) -> str:
"""Returns plugins's full name."""
return "%s@%s" % (cls.get_name(), cls.get_platform() or "")
@classmethod
def is_hidden(cls):
def is_hidden(cls) -> bool:
"""Returns whatever plugin is hidden or not."""
return cls._meta_get("hidden", False)
@classmethod
def is_deprecated(cls):
def is_deprecated(cls) -> DeprecationInfo | t.Literal[False]:
"""Returns deprecation details if plugin is deprecated."""
return cls._meta_get("deprecated", False)

View File

@@ -13,18 +13,28 @@
# License for the specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import abc
import traceback
import typing as t
from rally.common import logging
from rally.common.plugin import plugin
from rally import exceptions
if t.TYPE_CHECKING: # pragma: no cover
P = t.TypeVar("P", bound=plugin.Plugin)
V = t.TypeVar("V", bound="Validator")
LOG = logging.getLogger(__name__)
def configure(name, platform="default"):
def wrapper(cls):
def configure(
name: str, platform: str = "default"
) -> t.Callable[[type[V]], type[V]]:
"""Configure validator plugin with name and platform."""
def wrapper(cls: type[V]) -> type[V]:
return plugin.configure(name=name, platform=platform)(cls)
return wrapper
@@ -34,11 +44,17 @@ def configure(name, platform="default"):
class Validator(plugin.Plugin, metaclass=abc.ABCMeta):
"""A base class for all validators."""
def __init__(self):
def __init__(self) -> None:
pass
@abc.abstractmethod
def validate(self, context, config, plugin_cls, plugin_cfg):
def validate(
self,
context: dict[str, t.Any],
config: dict[str, t.Any] | None,
plugin_cls: type[plugin.Plugin],
plugin_cfg: dict[str, t.Any] | None
) -> None:
"""Method that validates something.
:param context: a validation context
@@ -50,11 +66,11 @@ class Validator(plugin.Plugin, metaclass=abc.ABCMeta):
"""
@staticmethod
def fail(msg):
def fail(msg: str) -> t.NoReturn:
raise ValidationError(msg)
@classmethod
def _get_doc(cls):
def _get_doc(cls) -> str:
doc = ""
if cls.__doc__ is not None:
doc = cls.__doc__
@@ -68,7 +84,7 @@ class Validator(plugin.Plugin, metaclass=abc.ABCMeta):
@configure(name="required_platform")
class RequiredPlatformValidator(Validator):
def __init__(self, platform, **kwargs):
def __init__(self, platform: str, **kwargs: t.Any) -> None:
"""Validates specification of specified platform for the workload.
:param platform: name of the platform
@@ -77,7 +93,13 @@ class RequiredPlatformValidator(Validator):
self.platform = platform
self._kwargs = kwargs
def validate(self, context, config, plugin_cls, plugin_cfg):
def validate(
self,
context: dict[str, t.Any],
config: dict[str, t.Any] | None,
plugin_cls: type[plugin.Plugin],
plugin_cfg: dict[str, t.Any] | None
) -> None:
try:
pvalidator_cls = RequiredPlatformValidator.get(
"required_platform",
@@ -103,12 +125,12 @@ class RequiredPlatformValidator(Validator):
"You should specify admin=True or users=True or both "
"for validating openstack platform.")
context = context["platforms"].get(self.platform, {})
platform_context = context["platforms"].get(self.platform, {})
if admin and context.get("admin") is None:
if admin and platform_context.get("admin") is None:
self.fail("No admin credential for %s" % self.platform)
if users and len(context.get("users", ())) == 0:
if context.get("admin") is None:
if users and len(platform_context.get("users", ())) == 0:
if platform_context.get("admin") is None:
self.fail("No user credentials for %s" % self.platform)
else:
# NOTE(andreykurilin): It is a case when the plugin
@@ -125,7 +147,7 @@ class RequiredPlatformValidator(Validator):
plugin_cfg=plugin_cfg)
def add(name, **kwargs):
def add(name: str, **kwargs: t.Any) -> t.Callable[[type[P]], type[P]]:
"""Add validator to the plugin class meta.
Add validator name and arguments to validators list stored in the
@@ -137,7 +159,7 @@ def add(name, **kwargs):
instance
"""
def wrapper(plugin):
def wrapper(plugin: type[P]) -> type[P]:
if issubclass(plugin, RequiredPlatformValidator):
raise exceptions.RallyException(
"Cannot add a validator to RequiredPlatformValidator")
@@ -153,7 +175,7 @@ def add(name, **kwargs):
return wrapper
def add_default(name, **kwargs):
def add_default(name: str, **kwargs: t.Any) -> t.Callable[[type[P]], type[P]]:
"""Add validator to the plugin class default meta.
Validator is added to all subclasses by default
@@ -162,7 +184,7 @@ def add_default(name, **kwargs):
:param kwargs: dict, validator plugin arguments
"""
def wrapper(plugin):
def wrapper(plugin: type[P]) -> type[P]:
plugin._default_meta_setdefault("validators", [])
plugin._default_meta_get("validators").append((name, (), kwargs,))
return plugin
@@ -172,22 +194,32 @@ def add_default(name, **kwargs):
# this class doesn't inherit from rally.exceptions.RallyException, since
# ValidationError should be used only for inner purpose.
class ValidationError(Exception):
def __init__(self, message):
def __init__(self, message: str) -> None:
super(ValidationError, self).__init__(message)
self.message = message
class ValidatablePluginMixin(object):
_ValidatorInfo = tuple[type[Validator], tuple[t.Any, ...], dict[str, t.Any]]
class ValidatablePluginMixin:
@staticmethod
def _load_validators(plugin):
def _load_validators(plugin: type[plugin.Plugin]) -> list[_ValidatorInfo]:
validators = plugin._meta_get("validators", default=[])
return [(Validator.get(name), args, kwargs)
for name, args, kwargs in validators]
@classmethod
def validate(cls, name, context, config, plugin_cfg,
allow_hidden=False, vtype=None):
def validate(
cls,
name: str,
context: dict[str, t.Any],
config: dict[str, t.Any] | None,
plugin_cfg: dict[str, t.Any] | None,
allow_hidden: bool = False,
vtype: str | list[str] | tuple[str, ...] | None = None
) -> list[str]:
"""Execute all validators stored in meta of plugin.
Iterate during all validators stored in the meta of Validator
@@ -205,7 +237,8 @@ class ValidatablePluginMixin(object):
:returns: list of ValidationResult(is_valid=False) instances
"""
try:
plugin = cls.get(name, allow_hidden=allow_hidden)
plugin_cls = t.cast(plugin.Plugin, cls).get(
name, allow_hidden=allow_hidden)
except exceptions.PluginNotFound as e:
return [e.format_message()]
@@ -224,38 +257,40 @@ class ValidatablePluginMixin(object):
syntax = "syntax" in vtype
platform = "platform" in vtype
syntax_validators = []
platform_validators = []
regular_validators = []
syntax_validators: list[_ValidatorInfo] = []
platform_validators: list[_ValidatorInfo] = []
regular_validators: list[_ValidatorInfo] = []
plugin_validators = cls._load_validators(plugin)
for validator, args, kwargs in plugin_validators:
if issubclass(validator, RequiredPlatformValidator):
plugin_validators = cls._load_validators(plugin_cls)
for validator_cls, args, kwargs in plugin_validators:
if issubclass(validator_cls, RequiredPlatformValidator):
if platform:
platform_validators.append((validator, args, kwargs))
platform_validators.append((validator_cls, args, kwargs))
else:
validators_of_validators = cls._load_validators(validator)
validators_of_validators = cls._load_validators(validator_cls)
if validators_of_validators:
if semantic:
regular_validators.append((validator, args, kwargs))
regular_validators.append(
(validator_cls, args, kwargs))
if platform:
# Load platform validators from each validator
platform_validators.extend(validators_of_validators)
else:
if syntax:
syntax_validators.append((validator, args, kwargs))
syntax_validators.append((validator_cls, args, kwargs))
results = []
results: list[str] = []
for validators in (syntax_validators, platform_validators,
regular_validators):
for validator_cls, args, kwargs in validators:
validator = validator_cls(*args, **kwargs)
result = None
result: str | None = None
try:
validator.validate(context=context,
config=config,
plugin_cls=plugin,
plugin_cfg=plugin_cfg)
validator.validate(
context=context,
config=config,
plugin_cls=plugin_cls,
plugin_cfg=plugin_cfg)
except ValidationError as e:
result = e.message
except Exception:
@@ -263,13 +298,9 @@ class ValidatablePluginMixin(object):
result = traceback.format_exc()
if result:
results.append(
"%(base)s plugin '%(pname)s' doesn't pass %(vname)s "
"validation. Details: %(error)s" % {
"base": cls.__name__,
"pname": name,
"vname": validator_cls.get_fullname(),
"error": result
}
f"{cls.__name__} plugin '{name}' doesn't pass "
f"{validator_cls.get_fullname()} validation. "
f"Details: {result}"
)
if results:
break

View File

@@ -54,7 +54,9 @@ def find_exception(response: requests.Response) -> RallyException:
global _exception_map
if _exception_map is None:
_exception_map = dict(
(e.error_code, e) for e in discover.itersubclasses(RallyException))
(e.error_code, e) for e in discover.itersubclasses(RallyException)
if hasattr(e, "error_code")
)
exc_class = _exception_map.get(response.status_code, RallyException)
error_data = response.json()["error"]

View File

@@ -110,7 +110,9 @@ def _process_workload(workload, workload_cfg, pos):
additive_output_charts[i].add_iteration(additive["data"])
except IndexError:
chart_cls = plugin.Plugin.get(additive["chart_plugin"])
chart = chart_cls(
# FIXME(andreykurilin): we need to be more specific about
# plugin class
chart = chart_cls( # type: ignore[call-arg]
workload, title=additive["title"],
description=additive.get("description", ""),
label=additive.get("label", ""),