Skip to content

Commit

Permalink
Support read charmcraft.yaml
Browse files Browse the repository at this point in the history
Try read unified charmcraft.yaml first, that may included metadata,
actions, and config. Ignore metadata.yaml, actions.yaml, config.yaml if
they are exists in charmcraft.yaml
  • Loading branch information
syu-w committed Jul 19, 2023
1 parent dd4865f commit 4238afb
Show file tree
Hide file tree
Showing 3 changed files with 200 additions and 37 deletions.
15 changes: 15 additions & 0 deletions ops/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,21 @@ def from_yaml(
raw_actions = {}
return cls(meta, raw_actions)

@classmethod
def from_dict(
cls, metadata: Dict[str, Any],
actions: Optional[Dict[str, Any]] = None) -> 'CharmMeta':
"""Instantiate a :class:`CharmMeta` from dict of ``charmcraft.yaml``.
Args:
metadata: A dictionary that include charm metadata (name, relations, etc.)
actions: A dictionary of Actions for this charm.
"""
raw_actions = {}
if actions is not None and isinstance(actions, dict):
raw_actions = actions
return cls(metadata, raw_actions)


class RelationRole(enum.Enum):
"""An annotation for a charm's role in a relation.
Expand Down
121 changes: 84 additions & 37 deletions ops/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,57 +389,104 @@ def cleanup(self) -> None:
"""
self._backend._cleanup()

def _create_meta(self, charm_metadata: Optional[YAMLStringOrFile],
action_metadata: Optional[YAMLStringOrFile]) -> CharmMeta:
def _create_meta(self, charm_metadata_yaml: Optional[YAMLStringOrFile],
action_metadata_yaml: Optional[YAMLStringOrFile]) -> CharmMeta:
"""Create a CharmMeta object.
Handle the cases where a user doesn't supply explicit metadata snippets.
This will try to load metadata from ``<charm_dir>/charmcraft.yaml`` first, then
``<charm_dir>/metadata.yaml`` if charmcraft.yaml does not include metadata,
and ``<charm_dir>/actions.yaml`` if charmcraft.yaml does not include actions.
"""
filename = inspect.getfile(self._charm_cls)
charm_dir = pathlib.Path(filename).parents[1]

charm_metadata: Optional[Dict[str, Any]] = 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.is_file():
self._charm_dir = charm_dir
charmcraft_metadata = yaml.safe_load(charmcraft_meta.read_text())

# Load metadata from parameters if provided
if charm_metadata_yaml is not None:
if isinstance(charm_metadata_yaml, str):
charm_metadata_yaml = dedent(charm_metadata_yaml)
charm_metadata = yaml.safe_load(charm_metadata_yaml)
else:
# Check charmcraft.yaml for metadata if no metadata is provided
if charmcraft_metadata is not None:
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_metadata

# Still no metadata, check metadata.yaml
if charm_metadata is None:
metadata_path = charm_dir / 'metadata.yaml'
if metadata_path.is_file():
charm_metadata = yaml.safe_load(metadata_path.read_text())
self._charm_dir = charm_dir

# Use default metadata if metadata is not found
if charm_metadata is None:
metadata_path = charm_dir / 'metadata.yaml'
if metadata_path.is_file():
charm_metadata = metadata_path.read_text()
self._charm_dir = charm_dir
else:
# The simplest of metadata that the framework can support
charm_metadata = 'name: test-charm'
elif isinstance(charm_metadata, str):
charm_metadata = dedent(charm_metadata)

if action_metadata is None:
actions_path = charm_dir / 'actions.yaml'
if actions_path.is_file():
action_metadata = actions_path.read_text()
self._charm_dir = charm_dir
elif isinstance(action_metadata, str):
action_metadata = dedent(action_metadata)

return CharmMeta.from_yaml(charm_metadata, action_metadata)

def _get_config(self, charm_config: Optional['YAMLStringOrFile']):
charm_metadata = {"name": "test-charm"}

action_metadata: Optional[Dict[str, Any]] = None
# Load actions from parameters if provided
if action_metadata_yaml is not None:
if isinstance(action_metadata_yaml, str):
action_metadata_yaml = dedent(action_metadata_yaml)
action_metadata = yaml.safe_load(action_metadata_yaml)
else:
# Check charmcraft.yaml for actions if no actions are provided
if charmcraft_metadata is not None and "actions" in charmcraft_metadata:
action_metadata = charmcraft_metadata["actions"]

# Still no actions, check actions.yaml
if action_metadata is None:
actions_path = charm_dir / 'actions.yaml'
if actions_path.is_file():
action_metadata = yaml.safe_load(actions_path.read_text())
self._charm_dir = charm_dir

return CharmMeta.from_dict(charm_metadata, action_metadata)

def _get_config(self, charm_config_yaml: Optional['YAMLStringOrFile']):
"""If the user passed a config to Harness, use it.
Otherwise, attempt to load one from charm_dir/config.yaml.
Otherwise try to load config from ``<charm_dir>/charmcraft.yaml`` first, then
``<charm_dir>/config.yaml`` if charmcraft.yaml does not include config.
"""
filename = inspect.getfile(self._charm_cls)
charm_dir = pathlib.Path(filename).parents[1]
config: Optional[Dict[str, Any]] = None

if charm_config is None:
config_path = charm_dir / 'config.yaml'
if config_path.is_file():
charm_config = config_path.read_text()
self._charm_dir = charm_dir
else:
# The simplest of config that the framework can support
charm_config = '{}'
elif isinstance(charm_config, str):
charm_config = dedent(charm_config)

assert isinstance(charm_config, str) # type guard
config = yaml.safe_load(charm_config)
# Load config from parameters if provided
if charm_config_yaml is not None:
if isinstance(charm_config_yaml, str):
charm_config_yaml = dedent(charm_config_yaml)
config = yaml.safe_load(charm_config_yaml)
else:
# Check charmcraft.yaml for config if no config is provided
charmcraft_meta = charm_dir / "charmcraft.yaml"
if charmcraft_meta.is_file():
charmcraft_metadata: dict[str, Any] = yaml.safe_load(charmcraft_meta.read_text())
config = charmcraft_metadata.get("config")

# Still no config, check config.yaml
if config is None:
config_path = charm_dir / 'config.yaml'
if config_path.is_file():
config = yaml.safe_load(config_path.read_text())
self._charm_dir = charm_dir

# Use default config if config is not found
if config is None:
config = {}

if not isinstance(config, dict):
raise TypeError(config)
Expand Down
101 changes: 101 additions & 0 deletions test/test_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -1262,6 +1262,32 @@ def test_metadata_from_directory(self):
# The charm_dir also gets set
self.assertEqual(harness.framework.charm_dir, tmp)

def test_metadata_from_directory_charmcraft_yaml(self):
tmp = pathlib.Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, str(tmp))
charmcraft_filename = tmp / 'charmcraft.yaml'
with charmcraft_filename.open('wt') as charmcraft_yaml:
charmcraft_yaml.write(textwrap.dedent('''
type: charm
bases:
- build-on:
- name: ubuntu
channel: "22.04"
run-on:
- name: ubuntu
channel: "22.04"
name: my-charm
requires:
db:
interface: pgsql
'''))
harness = self._get_dummy_charm_harness(tmp)
harness.begin()
self.assertEqual(list(harness.model.relations), ['db'])
# The charm_dir also gets set
self.assertEqual(harness.framework.charm_dir, tmp)

def test_config_from_directory(self):
tmp = pathlib.Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, str(tmp))
Expand Down Expand Up @@ -1302,6 +1328,56 @@ def test_config_from_directory(self):
self.assertIsNone(harness._backend._config._defaults['opt_null'])
self.assertIsNone(harness._backend._config._defaults['opt_no_default'])

def test_config_from_directory_charmcraft_yaml(self):
tmp = pathlib.Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, str(tmp))
charmcraft_filename = tmp / 'charmcraft.yaml'
with charmcraft_filename.open('wt') as charmcraft_yaml:
charmcraft_yaml.write(textwrap.dedent('''
type: charm
bases:
- build-on:
- name: ubuntu
channel: "22.04"
run-on:
- name: ubuntu
channel: "22.04"
config:
options:
opt_str:
type: string
default: "val"
opt_str_empty:
type: string
default: ""
opt_null:
type: string
default: null
opt_bool:
type: boolean
default: true
opt_int:
type: int
default: 1
opt_float:
type: float
default: 1.0
opt_no_default:
type: string
'''))
harness = self._get_dummy_charm_harness(tmp)
self.assertEqual(harness.model.config['opt_str'], 'val')
self.assertEqual(harness.model.config['opt_str_empty'], '')
self.assertIs(harness.model.config['opt_bool'], True)
self.assertEqual(harness.model.config['opt_int'], 1)
self.assertIsInstance(harness.model.config['opt_int'], int)
self.assertEqual(harness.model.config['opt_float'], 1.0)
self.assertIsInstance(harness.model.config['opt_float'], float)
self.assertFalse('opt_null' in harness.model.config)
self.assertIsNone(harness._backend._config._defaults['opt_null'])
self.assertIsNone(harness._backend._config._defaults['opt_no_default'])

def test_set_model_name(self):
harness = ops.testing.Harness(ops.CharmBase, meta='''
name: test-charm
Expand Down Expand Up @@ -1721,6 +1797,31 @@ def test_actions_from_directory(self):
# The charm_dir also gets set
self.assertEqual(harness.framework.charm_dir, tmp)

def test_actions_from_directory_charmcraft_yaml(self):
tmp = pathlib.Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, str(tmp))
charmcraft_filename = tmp / 'charmcraft.yaml'
with charmcraft_filename.open('wt') as charmcraft_yaml:
charmcraft_yaml.write(textwrap.dedent('''
type: charm
bases:
- build-on:
- name: ubuntu
channel: "22.04"
run-on:
- name: ubuntu
channel: "22.04"
actions:
test:
description: a dummy action
'''))
harness = self._get_dummy_charm_harness(tmp)
harness.begin()
self.assertEqual(list(harness.framework.meta.actions), ['test'])
# The charm_dir also gets set
self.assertEqual(harness.framework.charm_dir, tmp)

def _get_dummy_charm_harness(self, tmp):
self._write_dummy_charm(tmp)
charm_mod = importlib.import_module('testcharm')
Expand Down

0 comments on commit 4238afb

Please sign in to comment.