Skip to content

Commit

Permalink
Make molecule collection-aware (#4340)
Browse files Browse the repository at this point in the history
Fixes #4000

Two new Config properties named `collection` and `collection_path` will
be populated if Molecule detects it is running inside a collection.
  • Loading branch information
Qalthos authored Dec 13, 2024
1 parent 9026471 commit 46fcf3e
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 5 deletions.
49 changes: 48 additions & 1 deletion src/molecule/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
from molecule.dependency.base import Base as Dependency
from molecule.driver.base import Driver
from molecule.state import State
from molecule.types import CommandArgs, ConfigData, MoleculeArgs
from molecule.types import CollectionData, CommandArgs, ConfigData, MoleculeArgs
from molecule.verifier.base import Verifier


Expand Down Expand Up @@ -231,6 +231,28 @@ def cache_directory(
"""
return "molecule_parallel" if self.is_parallel else "molecule"

@property
def collection_directory(self) -> Path | None:
"""Location of collection containing the molecule files.
Returns:
Root of the collection containing the molecule files.
"""
test_paths = [Path.cwd(), Path(self.project_directory)]

for path in test_paths:
if (path / "galaxy.yml").exists():
return path

# Last resort, try to find git root
show_toplevel = util.run_command("git rev-parse --show-toplevel")
if show_toplevel.returncode == 0:
path = Path(show_toplevel.stdout.strip())
if (path / "galaxy.yml").exists():
return path

return None

@property
def molecule_directory(self) -> str:
"""Molecule directory for this project.
Expand All @@ -240,6 +262,31 @@ def molecule_directory(self) -> str:
"""
return molecule_directory(self.project_directory)

@cached_property
def collection(self) -> CollectionData | None:
"""Collection metadata sourced from galaxy.yml.
Returns:
A dictionary of information about the collection molecule is running inside, if any.
"""
collection_directory = self.collection_directory
if not collection_directory:
return None

galaxy_file = collection_directory / "galaxy.yml"
galaxy_data: CollectionData = util.safe_load_file(galaxy_file)

important_keys = {"name", "namespace"}
if missing_keys := important_keys.difference(galaxy_data.keys()):
LOG.warning(
"The detected galaxy.yml file (%s) is invalid, missing mandatory field %s",
galaxy_file,
util.oxford_comma(missing_keys),
)
return None # pragma: no cover

return galaxy_data

@cached_property
def dependency(self) -> Dependency | None:
"""Dependency manager in use.
Expand Down
26 changes: 26 additions & 0 deletions src/molecule/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,32 @@
Options: TypeAlias = MutableMapping[str, str | bool]


class CollectionData(TypedDict, total=False):
"""Collection metadata sourced from galaxy.yml.
Attributes:
name: The name of the collection.
namespace: The collection namespace.
version: The collection's version.
readme: Path to the README file.
authors: List of authors of the collection.
description: Description of the collection.
repository: URL of the collection's online repository.
license_file: Path to the collection's LICENSE file.
tags: List of tags applied to the collection.
"""

name: str
namespace: str
version: str
readme: str
authors: list[str]
description: str
repository: str
license_file: str
tags: list[str]


class DependencyData(TypedDict, total=False):
"""Molecule dependency configuration.
Expand Down
21 changes: 21 additions & 0 deletions src/molecule/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,3 +597,24 @@ def print_as_yaml(data: object) -> None:
# https://github.com/Textualize/rich/discussions/990#discussioncomment-342217
result = Syntax(code=safe_dump(data), lexer="yaml", background_color="default")
console.print(result)


def oxford_comma(listed: Iterable[bool | str | Path], condition: str = "and") -> str:
"""Format a list into a sentence.
Args:
listed: List of string entries to modify
condition: String to splice into string, usually 'and'
Returns:
Modified string
"""
match [f"'{entry!s}'" for entry in listed]:
case [one]:
return one
case [one, two]:
return f"{one} {condition} {two}"
case [*front, back]:
return f"{', '.join(s for s in front)}, {condition} {back}"
case _:
return ""
3 changes: 3 additions & 0 deletions tests/fixtures/resources/broken-collection/galaxy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: baddies
name_space: acme
version: 1.0.0
73 changes: 71 additions & 2 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
# DEALINGS IN THE SOFTWARE.
from __future__ import annotations

import copy
import os

from pathlib import Path
from typing import TYPE_CHECKING, Literal

import pytest
Expand Down Expand Up @@ -84,16 +86,83 @@ def test_init_calls_validate( # noqa: D103
patched_config_validate.assert_called_once_with()


def test_collection_directory_property(
config_instance: config.Config,
resources_folder_path: Path,
) -> None:
"""Test collection_directory property.
Args:
config_instance: Instance of Config.
resources_folder_path: Path to resources directory holding a valid collection.
"""
# default path is not in a collection
assert config_instance.collection_directory is None

# Alter config_instance to start at path of a collection
config_instance = copy.copy(config_instance)
collection_path = resources_folder_path / "sample-collection"
config_instance.project_directory = str(collection_path)
assert config_instance.collection_directory == collection_path


def test_project_directory_property(config_instance: config.Config) -> None: # noqa: D103
assert os.getcwd() == config_instance.project_directory # noqa: PTH109
assert str(Path.cwd()) == config_instance.project_directory


def test_molecule_directory_property(config_instance: config.Config) -> None: # noqa: D103
x = os.path.join(os.getcwd(), "molecule") # noqa: PTH109, PTH118
x = str(Path.cwd() / "molecule")

assert x == config_instance.molecule_directory


def test_collection_property(
config_instance: config.Config,
resources_folder_path: Path,
) -> None:
"""Test collection property.
Args:
config_instance: Instance of Config.
resources_folder_path: Path to resources directory holding a valid collection.
"""
modified_instance = copy.copy(config_instance)
# default path is not in a collection
assert config_instance.collection is None

# Alter config_instance to start at path of a collection
collection_path = resources_folder_path / "sample-collection"
modified_instance.project_directory = str(collection_path)

assert modified_instance.collection is not None
assert modified_instance.collection["name"] == "goodies"
assert modified_instance.collection["namespace"] == "acme"


def test_collection_property_broken_collection(
caplog: pytest.LogCaptureFixture,
config_instance: config.Config,
resources_folder_path: Path,
) -> None:
"""Test collection property with a malformed galaxy.yml.
Args:
caplog: pytest log capture fixture.
config_instance: Instance of Config.
resources_folder_path: Path to resources directory holding a valid collection.
"""
modified_instance = copy.copy(config_instance)

# Alter config_instance to start at path of a collection
collection_path = resources_folder_path / "broken-collection"
modified_instance.project_directory = str(collection_path)

assert modified_instance.collection is None

msg = "missing mandatory field 'namespace'"
assert msg in caplog.text


def test_dependency_property(config_instance: config.Config) -> None: # noqa: D103
assert isinstance(config_instance.dependency, ansible_galaxy.AnsibleGalaxy)

Expand Down
24 changes: 22 additions & 2 deletions tests/unit/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ def test_abs_path_with_symlink() -> None:

@pytest.mark.parametrize(
("a", "b", "x"),
[ # noqa: PT007
(
# Base of recursion scenarios
({"key": 1}, {"key": 2}, {"key": 2}),
({"key": {}}, {"key": 2}, {"key": 2}),
Expand All @@ -368,7 +368,27 @@ def test_abs_path_with_symlink() -> None:
{"a": 1, "b": [{"c": 3}], "d": {"e": "bbb"}},
{"a": 1, "b": [{"c": 3}], "d": {"e": "bbb", "f": 3}},
),
],
),
)
def test_merge_dicts(a: MutableMapping, b: MutableMapping, x: MutableMapping) -> None: # type: ignore[type-arg] # noqa: D103
assert x == util.merge_dicts(a, b)


@pytest.mark.parametrize(
("sequence", "output"),
(
([], ""),
(["item1"], "'item1'"),
(["item1", False], "'item1' and 'False'"),
(["item1", False, Path()], "'item1', 'False', and '.'"),
),
ids=("empty", "one", "two", "three"),
)
def test_oxford_comma(sequence: list[str], output: str) -> None:
"""Test the oxford_comma function.
Args:
sequence: sequence of items.
output: expected output string.
"""
assert util.oxford_comma(sequence) == output

0 comments on commit 46fcf3e

Please sign in to comment.