Source code for ops_tools._generate_yaml

# Copyright 2025 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Generate charmcraft.yaml config and actions from Python classes."""

from __future__ import annotations

import dataclasses
import enum
import logging
import re
from typing import (
    Any,
    Final,
    Generator,
    Mapping,
    TypedDict,
    get_args,
    get_origin,
    get_type_hints,
)

from typing_extensions import NotRequired

import ops

from . import _attrdocs

logger = logging.getLogger(__name__)


[docs] class OptionDict(TypedDict): type: str """The Juju option type.""" description: NotRequired[str] default: NotRequired[bool | int | float | str]
[docs] class ActionDict(TypedDict, total=False): description: str params: dict[str, Any] """A dictionary of parameters for the action.""" required: list[str] """A list of required parameters for the action.""" additionalProperties: bool """Whether additional properties are allowed in the action parameters."""
JUJU_TYPES: Final[Mapping[type, str]] = { bool: 'boolean', int: 'int', float: 'float', str: 'string', ops.Secret: 'secret', } # We currently only handle the basic types that we expect to see in real charms. # lists and tuples (arrays) are handled without using this mapping. JSON_TYPES: Final[Mapping[type, str]] = { bool: 'boolean', int: 'integer', float: 'number', str: 'string', } def attr_to_default(cls: type[object], name: str) -> object: """Get the default value for the attribute.""" if not dataclasses.is_dataclass(cls): return getattr(cls, name, None) default = None for field in dataclasses.fields(cls): if field.name != name: continue break else: return default # This might be a Pydantic dataclass using a Pydantic.Field object. field_default = ( # type: ignore field.default.default # type: ignore if hasattr(field.default, 'default') else field.default ) field_default_factory = ( # type: ignore field.default.default_factory # type: ignore if hasattr(field.default, 'default_factory') else field.default_factory ) # A hack to avoid importing Pydantic here. if ( 'PydanticUndefinedType' not in str(type(field_default)) # type: ignore and field_default is not dataclasses.MISSING ): return field_default # type: ignore if field_default_factory is not dataclasses.MISSING: return field_default_factory() # type: ignore return default def _attr_to_yaml_type(cls: type[object], name: str, yaml_types: dict[type, str]) -> str: try: raw_hint = get_type_hints(cls)[name] except KeyError: pass else: # Collapse Optional[] and Union[] and so on to the simpler form. origin = get_origin(raw_hint) if origin in (list, tuple): return yaml_types[origin] elif origin: hints = {arg for arg in get_args(raw_hint) if arg in yaml_types} else: hints = {raw_hint} # If there are multiple types -- for example, the type annotation is # `int | str` -- then we can't determine the type, and we fall back to # "string", even if `str` is not in the type hint, because our # "we can't determine the type" choice is always "string". if len(hints) > 1: return 'string' elif hints: try: return yaml_types[hints.pop()] except KeyError: pass # If we can't figure it out, then use "string", which should be the most # compatible, and most likely to be used for arbitrary types. Charms can # override `to_juju_schema` to adjust this if required. return 'string' def attr_to_juju_type(cls: type[object], name: str) -> str: """Provide the appropriate type for the config YAML for the given class attribute. If an appropriate type cannot be determined, fall back to "string". """ return _attr_to_yaml_type(cls, name, JUJU_TYPES) def attr_to_json_type(cls: type[object], name: str) -> str: """Provide the appropriate type for the action YAML for the given attribute. If an appropriate type cannot be determined, fall back to "string". """ return _attr_to_yaml_type(cls, name, JSON_TYPES) def juju_schema_from_model_fields(cls: type[object]) -> dict[str, OptionDict]: """Generate a Juju schema from the model fields of a Pydantic model.""" # The many type: ignores are required because we don't want to import # pydantic in this code. options: dict[str, OptionDict] = {} for name, field in cls.model_fields.items(): # type: ignore option = {} if 'PydanticUndefinedType' not in str(type(field.default)) and field.default is not None: # type: ignore default = field.default # type: ignore elif field.default_factory is not None: # type: ignore default = field.default_factory() # type: ignore else: default = None if default is not None: if type(default) in JUJU_TYPES: # type: ignore option['default'] = default else: option['default'] = str(default) # type: ignore if field.annotation in (bool, int, float, str, ops.Secret): # type: ignore hint = JUJU_TYPES[field.annotation] else: hint = field.annotation # type: ignore if get_origin(hint): # type: ignore hints = {arg for arg in get_args(hint) if arg in JUJU_TYPES} if len(hints) > 1: hint = type(str) elif hints: hint = hints.pop() hint = JUJU_TYPES.get(hint, 'string') # type: ignore option['type'] = hint if field.description: # type: ignore option['description'] = field.description # type: ignore options[name.replace('_', '-')] = option # type: ignore return options def juju_names(cls: type[object]) -> Generator[str]: """Iterates over all the names to include in the config or action YAML.""" if dataclasses.is_dataclass(cls): for field in dataclasses.fields(cls): yield field.name return if hasattr(cls, 'model_fields'): for field in cls.model_fields.values(): # type: ignore yield field.name # type: ignore return # If this isn't a dataclass or a Pydantic model, then fall back to using # any class attribute with a type annotation. yield from get_type_hints(cls) def to_json_schema(cls: type[object]) -> tuple[dict[str, Any], list[str]]: """Translate the class to JSONSchema suitable for use in ``charmcraft.yaml``. This only handles simple types (strings, Booleans, integers, floats, tuples, and lists). Returns a dictionary that can be dumped to YAML and a list of required params. """ # As of March 2025, most charms use only simple parameter types, despite # being able to use anything that JSONSchema offers. The 'type' breakdown # among the charms analysed is: # * 'string': 158 # * 'boolean': 16 # * 'array': 10 # * 'integer': 7 # * 'number': 2 # * 'object': 1 # Only one charm has a `properties' field that further defines the # parameter. It seems reasonable to handle all of the simple cases and # require anyone using anything more complicated to provide a custom # to_juju_schema and provide their own details (or to use a Pydantic # class). attr_docs = _attrdocs.get_attr_docstrings(cls) params: dict[str, Any] = {} required_params: list[str] = [] for attr in sorted(juju_names(cls)): param = {} hint_obj = get_type_hints(cls)[attr] origin = get_origin(hint_obj) args = get_args(hint_obj) if isinstance(hint_obj, type) and issubclass(hint_obj, enum.Enum): param['type'] = 'string' param['enum'] = [m.value for m in hint_obj.__members__.values()] elif isinstance(origin, type) and issubclass(origin, (list, tuple)): param['type'] = 'array' if issubclass(origin, list) and len(args) == 1: param['items'] = {'type': JSON_TYPES.get(args[0], 'string')} else: param['type'] = attr_to_json_type(cls, attr) default = attr_to_default(cls, attr) if default is None: required = True else: required = False if type(default) not in (bool, int, float, str, list, tuple): default = str(default) if not issubclass(type(default), (list, tuple)) or len(default) > 0: param['default'] = default doc = attr_docs.get(attr) if doc: param['description'] = doc json_name = attr.replace('_', '-') params[json_name] = param if required: required_params.append(json_name) required_params.sort() return params, required_params
[docs] def config_to_juju_schema(cls: type[object]) -> dict[str, dict[str, OptionDict]]: """Translate the class to YAML suitable for charmcraft.yaml. For example:: >>> import pydantic >>> import yaml >>> class MyConfig(pydantic.BaseModel): ... my_bool: bool = pydantic.Field(default=False, description='A boolean value.') ... my_float: float = pydantic.Field( ... default=3.14, description='A floating point value.' ... ) ... my_int: int = pydantic.Field(default=42, description='An integer value.') ... my_str: str = pydantic.Field(default="foo", description='A string value.') ... my_secret: ops.Secret | None = pydantic.Field( ... default=None, description='A user secret.' ... ) ... class Config: ... arbitrary_types_allowed = True >>> print(yaml.safe_dump(config_to_juju_schema(MyConfig))) options: my-bool: default: false description: A boolean value. type: boolean my-float: default: 3.14 description: A floating point value. type: float my-int: default: 42 description: An integer value. type: int my-secret: description: A user secret. type: secret my-str: default: foo description: A string value. type: string <BLANKLINE> Options with a default value of ``None`` will not have a ``default`` key in the output. If the type of the option cannot be determined, it will be set to ``string``. If there is a default value, but it is not one of the Juju option types, the ``str()`` representation of the value will be used. To customise, define a ``to_juju_schema`` method in your class. For example:: @classmethod def to_juju_schema(cls, schema: dict[str, OptionDict]) -> dict[str, OptionDict]: # Change the key names to upper-case. schema = {key.upper(): value for key, value in schema.items()} return schema """ if hasattr(cls, 'model_fields'): # Special-case pydantic BaseModel or anything else with a compatible # `model_fields`` attribute. options = juju_schema_from_model_fields(cls) else: # Dataclasses, regular classes, etc. attr_docstrings = _attrdocs.get_attr_docstrings(cls) options: dict[str, OptionDict] = {} for attr in sorted(juju_names(cls)): option: OptionDict = {'type': attr_to_juju_type(cls, attr)} default = attr_to_default(cls, attr) if default is not None: if type(default) in JUJU_TYPES: option['default'] = default else: option['default'] = str(default) doc = attr_docstrings.get(attr) if doc: option['description'] = doc options[attr.replace('_', '-')] = option if hasattr(cls, 'to_juju_schema'): # If the class has a custom `to_juju_schema` method, call it. # This allows the class to override the default schema generation. return {'options': cls.to_juju_schema(options)} # type: ignore return {'options': options}
[docs] def action_to_juju_schema(cls: type[object]) -> dict[str, Any]: """Translate the class to a dictionary suitable for ``charmcraft.yaml``. For example:: >>> import enum >>> import pydantic >>> import yaml >>> class RunBackup(pydantic.BaseModel): ... '''Backup the database.''' ... class Compression(enum.Enum): ... GZ = 'gzip' ... BZ = 'bzip2' ... ... filename: str = pydantic.Field(description='The name of the backup file.') ... compression: Compression = pydantic.Field( ... Compression.GZ, ... description='The type of compression to use.', ... ) >>> print(yaml.safe_dump(action_to_juju_schema(RunBackup))) run-backup: additionalProperties: false description: Backup the database. params: compression: type: string default: gzip description: The type of compression to use. enum: [gzip, bzip2] filename: description: The name of the backup file. title: Filename type: string required: - filename <BLANKLINE> >>> To adjust the YAML, provide a ``to_juju_schema`` method in the class. For example, to allow additional properties:: def to_juju_schema(cls, schema: dict[str, ActionDict]) -> dict[str, ActionDict]: schema['run-backup']['additionalProperties'] = True return schema """ # As of March 2025, there are no known charms that are using # execution-group or parallel, so we don't support those here. If any # charms do need them, they can change the output by defining a # `to_juju_schema` method. action = {} if cls.__doc__: action['description'] = cls.__doc__ # Pydantic classes provide this, so we can just get it directly. # The type: ignores are to avoid importing pydantic. if hasattr(cls, 'schema'): schema = cls.schema() # type: ignore params = {key.replace('_', '-'): value for key, value in schema['properties'].items()} # type: ignore required_params = [key.replace('_', '-') for key in schema['required']] # type: ignore required_params.sort() # type: ignore else: params, required_params = to_json_schema(cls) if params: action['params'] = params if required_params: action['required'] = required_params action['additionalProperties'] = False # Add a ``-`` after each A-Z character of the class name, and then # lower-case the resulting string. Drop any 'action' suffix. action_name = cls.__name__ if action_name.lower().endswith('action'): action_name = action_name[: -len('action')].rstrip('-') action_name = re.sub(r'(?<!^)([A-Z])', r'-\1', action_name).lower() if hasattr(cls, 'to_juju_schema'): # If the class has a custom `to_juju_schema` method, call it. # This allows the class to override the default schema generation. return cls.to_juju_schema({action_name: action}) # type: ignore return {action_name: action}