diff --git a/ops/charm.py b/ops/charm.py index 7d735e512..cccbbf915 100755 --- a/ops/charm.py +++ b/ops/charm.py @@ -1118,18 +1118,22 @@ def __init__(self, raw: Optional[Dict[str, Any]] = None, @classmethod def from_yaml( cls, metadata: Union[str, TextIO], - actions: Optional[Union[str, TextIO]] = None) -> 'CharmMeta': + actions: Optional[Union[str, TextIO, Dict[str, Any]]] = None) -> 'CharmMeta': """Instantiate a :class:`CharmMeta` from a YAML description of ``metadata.yaml``. Args: metadata: A YAML description of charm metadata (name, relations, etc.) This can be a simple string, or a file-like object (passed to ``yaml.safe_load``). - actions: YAML description of Actions for this charm (e.g., actions.yaml) + actions: YAML description of Actions for this charm (e.g., actions.yaml), + or a mapped dictionary of actions that could come from charmcraft.yaml. """ meta = yaml.safe_load(metadata) raw_actions = {} if actions is not None: - raw_actions = cast(Optional[Dict[str, Any]], yaml.safe_load(actions)) + if isinstance(actions, dict): + raw_actions = actions + else: + raw_actions = cast(Optional[Dict[str, Any]], yaml.safe_load(actions)) if raw_actions is None: raw_actions = {} return cls(meta, raw_actions) diff --git a/ops/main.py b/ops/main.py index 6bbc2d3fe..619ead7cc 100755 --- a/ops/main.py +++ b/ops/main.py @@ -32,6 +32,7 @@ import ops.framework import ops.model import ops.storage +from ops._private import yaml from ops.charm import CharmMeta from ops.jujuversion import JujuVersion from ops.log import setup_root_logging @@ -374,6 +375,8 @@ def main(charm_class: Type[ops.charm.CharmBase], are running on a new enough Juju default to controller-side storage, otherwise local storage is used. """ + metadata: Optional[str] = None + actions_metadata: Optional[Union[str, Dict[str, Any]]] = None charm_dir = _get_charm_dir() model_backend = ops.model._ModelBackend() @@ -384,12 +387,28 @@ def main(charm_class: Type[ops.charm.CharmBase], dispatcher = _Dispatcher(charm_dir) dispatcher.run_any_legacy_hook() - metadata = (charm_dir / 'metadata.yaml').read_text() - actions_meta = charm_dir / 'actions.yaml' - if actions_meta.exists(): - actions_metadata = actions_meta.read_text() - else: - actions_metadata = None + # Check charmcraft.yaml first and see if it has the metadata data in it. + charmcraft_meta = charm_dir / "charmcraft.yaml" + if charmcraft_meta.exists(): + charmcraft_yaml = charmcraft_meta.read_text() + charmcraft_metadata: dict[str, Any] = yaml.safe_load(charmcraft_yaml) + if any( + meta_key in charmcraft_metadata for meta_key in ("name", "summary", "description") + ): + # Unrelated keys in the charmcraft.yaml file will be ignored. + metadata = charmcraft_yaml + + # This could be None if the charmcraft.yaml not include the actions key. + # Should fall back to the actions.yaml file. + actions_metadata = charmcraft_metadata.get("actions") + + if not metadata: + metadata = (charm_dir / "metadata.yaml").read_text() + + if not actions_metadata: + actions_meta = charm_dir / "actions.yaml" + if actions_meta.exists(): + actions_metadata = actions_meta.read_text() meta = CharmMeta.from_yaml(metadata, actions_metadata) model = ops.model.Model(meta, model_backend) diff --git a/ops/testing.py b/ops/testing.py index a290be1ef..d9447d4fd 100755 --- a/ops/testing.py +++ b/ops/testing.py @@ -394,9 +394,30 @@ def _create_meta(self, charm_metadata: Optional[YAMLStringOrFile], """Create a CharmMeta object. Handle the cases where a user doesn't supply explicit metadata snippets. + Will try to load metadata/config from charm_dir/charmcraft.yaml first, then + charm_dir/metadata.yaml and/or charm_dir/actions.yaml if charmcraft.yaml + not include metadata and/or actions. """ filename = inspect.getfile(self._charm_cls) charm_dir = pathlib.Path(filename).parents[1] + charmcraft_yaml: Optional[str] = None + charmcraft_metadata: Optional[dict[str, Any]] = None + + # Check charmcraft.yaml and load it if it exists + charmcraft_meta = charm_dir / "charmcraft.yaml" + if charmcraft_meta.exists(): + self._charm_dir = charm_dir + charmcraft_yaml = charmcraft_meta.read_text() + charmcraft_metadata = yaml.safe_load(charmcraft_yaml) + + # Add metadata from charmcraft.yaml if no metadata is provided + # Should fall back to metadata.yaml if both are not provided + if charm_metadata is None and charmcraft_metadata: + if any( + meta_key in charmcraft_metadata for meta_key in ("name", "summary", "description") + ): + # Unrelated keys in the charmcraft.yaml file will be ignored. + charm_metadata = charmcraft_yaml if charm_metadata is None: metadata_path = charm_dir / 'metadata.yaml' @@ -409,6 +430,11 @@ def _create_meta(self, charm_metadata: Optional[YAMLStringOrFile], elif isinstance(charm_metadata, str): charm_metadata = dedent(charm_metadata) + # Add actions from charmcraft.yaml if no actions are provided + # Should fall back to actions.yaml if both are not provided + if action_metadata is None and charmcraft_metadata: + action_metadata = charmcraft_metadata.get("actions") + if action_metadata is None: actions_path = charm_dir / 'actions.yaml' if actions_path.is_file(): @@ -422,12 +448,23 @@ def _create_meta(self, charm_metadata: Optional[YAMLStringOrFile], def _get_config(self, charm_config: Optional['YAMLStringOrFile']): """If the user passed a config to Harness, use it. - Otherwise, attempt to load one from charm_dir/config.yaml. + Otherwise, attempt to load one from charm_dir/charmcraft.yaml, then + charm_dir/config.yaml if config is not provided in charmcraft.yaml. """ filename = inspect.getfile(self._charm_cls) charm_dir = pathlib.Path(filename).parents[1] - - if charm_config is None: + config: Optional[dict[str, Any]] = None + + # Check charmcraft.yaml first if no config is provided + charmcraft_meta = charm_dir / "charmcraft.yaml" + if charm_config is None and charmcraft_meta.exists(): + self._charm_dir = charm_dir + charmcraft_yaml = charmcraft_meta.read_text() + charmcraft_metadata: dict[str, Any] = yaml.safe_load(charmcraft_yaml) + config = charmcraft_metadata.get("config") + + # Add config from charmcraft.yaml if no config is provided + if config is None and charm_config is None: config_path = charm_dir / 'config.yaml' if config_path.is_file(): charm_config = config_path.read_text() @@ -438,8 +475,10 @@ def _get_config(self, charm_config: Optional['YAMLStringOrFile']): elif isinstance(charm_config, str): charm_config = dedent(charm_config) - assert isinstance(charm_config, str) # type guard - config = yaml.safe_load(charm_config) + # If we have a config dict already, ignore the charm_config + if not config: + assert isinstance(charm_config, str) # type guard + config = yaml.safe_load(charm_config) if not isinstance(config, dict): raise TypeError(config)