Skip to content

Commit 46fcf3e

Browse files
authored
Make molecule collection-aware (#4340)
Fixes #4000 Two new Config properties named `collection` and `collection_path` will be populated if Molecule detects it is running inside a collection.
1 parent 9026471 commit 46fcf3e

File tree

6 files changed

+191
-5
lines changed

6 files changed

+191
-5
lines changed

src/molecule/config.py

+48-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
from molecule.dependency.base import Base as Dependency
4949
from molecule.driver.base import Driver
5050
from molecule.state import State
51-
from molecule.types import CommandArgs, ConfigData, MoleculeArgs
51+
from molecule.types import CollectionData, CommandArgs, ConfigData, MoleculeArgs
5252
from molecule.verifier.base import Verifier
5353

5454

@@ -231,6 +231,28 @@ def cache_directory(
231231
"""
232232
return "molecule_parallel" if self.is_parallel else "molecule"
233233

234+
@property
235+
def collection_directory(self) -> Path | None:
236+
"""Location of collection containing the molecule files.
237+
238+
Returns:
239+
Root of the collection containing the molecule files.
240+
"""
241+
test_paths = [Path.cwd(), Path(self.project_directory)]
242+
243+
for path in test_paths:
244+
if (path / "galaxy.yml").exists():
245+
return path
246+
247+
# Last resort, try to find git root
248+
show_toplevel = util.run_command("git rev-parse --show-toplevel")
249+
if show_toplevel.returncode == 0:
250+
path = Path(show_toplevel.stdout.strip())
251+
if (path / "galaxy.yml").exists():
252+
return path
253+
254+
return None
255+
234256
@property
235257
def molecule_directory(self) -> str:
236258
"""Molecule directory for this project.
@@ -240,6 +262,31 @@ def molecule_directory(self) -> str:
240262
"""
241263
return molecule_directory(self.project_directory)
242264

265+
@cached_property
266+
def collection(self) -> CollectionData | None:
267+
"""Collection metadata sourced from galaxy.yml.
268+
269+
Returns:
270+
A dictionary of information about the collection molecule is running inside, if any.
271+
"""
272+
collection_directory = self.collection_directory
273+
if not collection_directory:
274+
return None
275+
276+
galaxy_file = collection_directory / "galaxy.yml"
277+
galaxy_data: CollectionData = util.safe_load_file(galaxy_file)
278+
279+
important_keys = {"name", "namespace"}
280+
if missing_keys := important_keys.difference(galaxy_data.keys()):
281+
LOG.warning(
282+
"The detected galaxy.yml file (%s) is invalid, missing mandatory field %s",
283+
galaxy_file,
284+
util.oxford_comma(missing_keys),
285+
)
286+
return None # pragma: no cover
287+
288+
return galaxy_data
289+
243290
@cached_property
244291
def dependency(self) -> Dependency | None:
245292
"""Dependency manager in use.

src/molecule/types.py

+26
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,32 @@
1212
Options: TypeAlias = MutableMapping[str, str | bool]
1313

1414

15+
class CollectionData(TypedDict, total=False):
16+
"""Collection metadata sourced from galaxy.yml.
17+
18+
Attributes:
19+
name: The name of the collection.
20+
namespace: The collection namespace.
21+
version: The collection's version.
22+
readme: Path to the README file.
23+
authors: List of authors of the collection.
24+
description: Description of the collection.
25+
repository: URL of the collection's online repository.
26+
license_file: Path to the collection's LICENSE file.
27+
tags: List of tags applied to the collection.
28+
"""
29+
30+
name: str
31+
namespace: str
32+
version: str
33+
readme: str
34+
authors: list[str]
35+
description: str
36+
repository: str
37+
license_file: str
38+
tags: list[str]
39+
40+
1541
class DependencyData(TypedDict, total=False):
1642
"""Molecule dependency configuration.
1743

src/molecule/util.py

+21
Original file line numberDiff line numberDiff line change
@@ -597,3 +597,24 @@ def print_as_yaml(data: object) -> None:
597597
# https://github.com/Textualize/rich/discussions/990#discussioncomment-342217
598598
result = Syntax(code=safe_dump(data), lexer="yaml", background_color="default")
599599
console.print(result)
600+
601+
602+
def oxford_comma(listed: Iterable[bool | str | Path], condition: str = "and") -> str:
603+
"""Format a list into a sentence.
604+
605+
Args:
606+
listed: List of string entries to modify
607+
condition: String to splice into string, usually 'and'
608+
609+
Returns:
610+
Modified string
611+
"""
612+
match [f"'{entry!s}'" for entry in listed]:
613+
case [one]:
614+
return one
615+
case [one, two]:
616+
return f"{one} {condition} {two}"
617+
case [*front, back]:
618+
return f"{', '.join(s for s in front)}, {condition} {back}"
619+
case _:
620+
return ""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
name: baddies
2+
name_space: acme
3+
version: 1.0.0

tests/unit/test_config.py

+71-2
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
# DEALINGS IN THE SOFTWARE.
2020
from __future__ import annotations
2121

22+
import copy
2223
import os
2324

25+
from pathlib import Path
2426
from typing import TYPE_CHECKING, Literal
2527

2628
import pytest
@@ -84,16 +86,83 @@ def test_init_calls_validate( # noqa: D103
8486
patched_config_validate.assert_called_once_with()
8587

8688

89+
def test_collection_directory_property(
90+
config_instance: config.Config,
91+
resources_folder_path: Path,
92+
) -> None:
93+
"""Test collection_directory property.
94+
95+
Args:
96+
config_instance: Instance of Config.
97+
resources_folder_path: Path to resources directory holding a valid collection.
98+
"""
99+
# default path is not in a collection
100+
assert config_instance.collection_directory is None
101+
102+
# Alter config_instance to start at path of a collection
103+
config_instance = copy.copy(config_instance)
104+
collection_path = resources_folder_path / "sample-collection"
105+
config_instance.project_directory = str(collection_path)
106+
assert config_instance.collection_directory == collection_path
107+
108+
87109
def test_project_directory_property(config_instance: config.Config) -> None: # noqa: D103
88-
assert os.getcwd() == config_instance.project_directory # noqa: PTH109
110+
assert str(Path.cwd()) == config_instance.project_directory
89111

90112

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

94116
assert x == config_instance.molecule_directory
95117

96118

119+
def test_collection_property(
120+
config_instance: config.Config,
121+
resources_folder_path: Path,
122+
) -> None:
123+
"""Test collection property.
124+
125+
Args:
126+
config_instance: Instance of Config.
127+
resources_folder_path: Path to resources directory holding a valid collection.
128+
"""
129+
modified_instance = copy.copy(config_instance)
130+
# default path is not in a collection
131+
assert config_instance.collection is None
132+
133+
# Alter config_instance to start at path of a collection
134+
collection_path = resources_folder_path / "sample-collection"
135+
modified_instance.project_directory = str(collection_path)
136+
137+
assert modified_instance.collection is not None
138+
assert modified_instance.collection["name"] == "goodies"
139+
assert modified_instance.collection["namespace"] == "acme"
140+
141+
142+
def test_collection_property_broken_collection(
143+
caplog: pytest.LogCaptureFixture,
144+
config_instance: config.Config,
145+
resources_folder_path: Path,
146+
) -> None:
147+
"""Test collection property with a malformed galaxy.yml.
148+
149+
Args:
150+
caplog: pytest log capture fixture.
151+
config_instance: Instance of Config.
152+
resources_folder_path: Path to resources directory holding a valid collection.
153+
"""
154+
modified_instance = copy.copy(config_instance)
155+
156+
# Alter config_instance to start at path of a collection
157+
collection_path = resources_folder_path / "broken-collection"
158+
modified_instance.project_directory = str(collection_path)
159+
160+
assert modified_instance.collection is None
161+
162+
msg = "missing mandatory field 'namespace'"
163+
assert msg in caplog.text
164+
165+
97166
def test_dependency_property(config_instance: config.Config) -> None: # noqa: D103
98167
assert isinstance(config_instance.dependency, ansible_galaxy.AnsibleGalaxy)
99168

tests/unit/test_util.py

+22-2
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ def test_abs_path_with_symlink() -> None:
354354

355355
@pytest.mark.parametrize(
356356
("a", "b", "x"),
357-
[ # noqa: PT007
357+
(
358358
# Base of recursion scenarios
359359
({"key": 1}, {"key": 2}, {"key": 2}),
360360
({"key": {}}, {"key": 2}, {"key": 2}),
@@ -368,7 +368,27 @@ def test_abs_path_with_symlink() -> None:
368368
{"a": 1, "b": [{"c": 3}], "d": {"e": "bbb"}},
369369
{"a": 1, "b": [{"c": 3}], "d": {"e": "bbb", "f": 3}},
370370
),
371-
],
371+
),
372372
)
373373
def test_merge_dicts(a: MutableMapping, b: MutableMapping, x: MutableMapping) -> None: # type: ignore[type-arg] # noqa: D103
374374
assert x == util.merge_dicts(a, b)
375+
376+
377+
@pytest.mark.parametrize(
378+
("sequence", "output"),
379+
(
380+
([], ""),
381+
(["item1"], "'item1'"),
382+
(["item1", False], "'item1' and 'False'"),
383+
(["item1", False, Path()], "'item1', 'False', and '.'"),
384+
),
385+
ids=("empty", "one", "two", "three"),
386+
)
387+
def test_oxford_comma(sequence: list[str], output: str) -> None:
388+
"""Test the oxford_comma function.
389+
390+
Args:
391+
sequence: sequence of items.
392+
output: expected output string.
393+
"""
394+
assert util.oxford_comma(sequence) == output

0 commit comments

Comments
 (0)