Skip to content

Commit bd636c7

Browse files
committed
Squashed all commits from PR #4499
1 parent a7f9f2d commit bd636c7

File tree

15 files changed

+702
-227
lines changed

15 files changed

+702
-227
lines changed

src/molecule/ansi_output.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,15 @@
2727

2828
from typing import TYPE_CHECKING
2929

30-
from typing_extensions import Self
31-
3230
from molecule.constants import DEFAULT_BORDER_WIDTH, MARKUP_MAP, SCENARIO_RECAP_STATE_ORDER
3331
from molecule.constants import ANSICodes as A
3432

3533

3634
if TYPE_CHECKING:
3735
from typing import TextIO
3836

37+
from typing_extensions import Self
38+
3939
from molecule.reporting import ScenariosResults
4040

4141

src/molecule/command/base.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
from wcmatch import glob
3939

4040
from molecule import config, logger, text, util
41-
from molecule.constants import MOLECULE_DEFAULT_SCENARIO_NAME, MOLECULE_GLOB
41+
from molecule.constants import MOLECULE_DEFAULT_SCENARIO_NAME
4242
from molecule.exceptions import ImmediateExit, MoleculeError, ScenarioFailureError
4343
from molecule.reporting import ScenarioResults, report
4444
from molecule.scenarios import Scenarios
@@ -138,25 +138,27 @@ def execute_cmdline_scenarios(
138138
if excludes is None:
139139
excludes = []
140140

141+
effective_base_glob = util.get_effective_molecule_glob()
142+
141143
configs: list[config.Config] = []
142144
if scenario_names is None:
143145
configs = [
144146
config
145-
for config in get_configs(args, command_args, ansible_args, MOLECULE_GLOB)
147+
for config in get_configs(args, command_args, ansible_args)
146148
if config.scenario.name not in excludes
147149
]
148150
else:
149151
try:
150152
# filter out excludes
151153
scenario_names = [name for name in scenario_names if name not in excludes]
152154
for scenario_name in scenario_names:
153-
glob_str = MOLECULE_GLOB.replace("*", scenario_name)
155+
glob_str = effective_base_glob.replace("*", scenario_name)
154156
configs.extend(get_configs(args, command_args, ansible_args, glob_str))
155157
except ScenarioFailureError as exc:
156158
msg = "Scenario configuration failed"
157159
raise ImmediateExit(msg, code=exc.code) from exc
158160

159-
default_glob = MOLECULE_GLOB.replace("*", MOLECULE_DEFAULT_SCENARIO_NAME)
161+
default_glob = effective_base_glob.replace("*", MOLECULE_DEFAULT_SCENARIO_NAME)
160162
default_config = None
161163
try:
162164
default_config = get_configs(args, command_args, ansible_args, default_glob)[0]
@@ -404,7 +406,7 @@ def get_configs(
404406
args: MoleculeArgs,
405407
command_args: CommandArgs,
406408
ansible_args: tuple[str, ...] = (),
407-
glob_str: str = MOLECULE_GLOB,
409+
glob_str: str | None = None,
408410
) -> list[config.Config]:
409411
"""Glob the current directory for Molecule config files.
410412
@@ -415,10 +417,14 @@ def get_configs(
415417
command_args: A dict of options passed to the subcommand from the CLI.
416418
ansible_args: An optional tuple of arguments provided to the `ansible-playbook` command.
417419
glob_str: A string representing the glob used to find Molecule config files.
420+
If None, uses util.get_effective_molecule_glob().
418421
419422
Returns:
420423
A list of Config objects.
421424
"""
425+
if glob_str is None:
426+
glob_str = util.get_effective_molecule_glob()
427+
422428
scenario_paths = glob.glob(
423429
glob_str,
424430
flags=wcmatch.pathlib.GLOBSTAR | wcmatch.pathlib.BRACE | wcmatch.pathlib.DOTGLOB,
@@ -439,16 +445,20 @@ def get_configs(
439445
return configs
440446

441447

442-
def _verify_configs(configs: list[config.Config], glob_str: str = MOLECULE_GLOB) -> None:
448+
def _verify_configs(configs: list[config.Config], glob_str: str | None = None) -> None:
443449
"""Verify a Molecule config was found and returns None.
444450
445451
Args:
446452
configs: A list containing absolute paths to Molecule config files.
447453
glob_str: A string representing the glob used to find Molecule config files.
454+
If None, uses util.get_effective_molecule_glob().
448455
449456
Raises:
450457
ScenarioFailureError: When scenario configs cannot be verified.
451458
"""
459+
if glob_str is None:
460+
glob_str = util.get_effective_molecule_glob()
461+
452462
if configs:
453463
scenario_names = [c.scenario.name for c in configs]
454464
for scenario_name, n in collections.Counter(scenario_names).items():

src/molecule/command/init/scenario.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

3131
import click
3232

33-
from molecule import api, logger
33+
from molecule import api, logger, util
3434
from molecule.click_cfg import click_command_ex
3535
from molecule.command.init import base
3636
from molecule.config import (
@@ -39,7 +39,11 @@
3939
Config,
4040
molecule_directory,
4141
)
42-
from molecule.constants import MOLECULE_DEFAULT_SCENARIO_NAME
42+
from molecule.constants import (
43+
MOLECULE_COLLECTION_ROOT,
44+
MOLECULE_DEFAULT_SCENARIO_NAME,
45+
MOLECULE_ROOT,
46+
)
4347
from molecule.exceptions import MoleculeError
4448

4549

@@ -113,13 +117,28 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002
113117

114118
msg = f"Initializing new scenario {scenario_name}..."
115119
self._log.info(msg)
116-
molecule_path = Path(molecule_directory(Path.cwd()))
120+
121+
# Use collection-aware molecule directory
122+
collection_dir, _ = util.get_collection_metadata()
123+
124+
if collection_dir:
125+
# We're in collection mode, use extensions/molecule
126+
molecule_path = collection_dir / MOLECULE_COLLECTION_ROOT
127+
relative_path = f"{MOLECULE_COLLECTION_ROOT}/{scenario_name}"
128+
else:
129+
# Standard mode, use molecule/
130+
molecule_path = Path(molecule_directory(Path.cwd()))
131+
relative_path = f"{MOLECULE_ROOT}/{scenario_name}"
132+
117133
scenario_directory = molecule_path / scenario_name
118134

119135
if scenario_directory.is_dir():
120-
msg = f"The directory molecule/{scenario_name} exists. Cannot create new scenario."
136+
msg = f"The directory {relative_path} exists. Cannot create new scenario."
121137
raise MoleculeError(msg)
122138

139+
# Ensure parent directory exists
140+
molecule_path.mkdir(parents=True, exist_ok=True)
141+
123142
extra_vars = json.dumps(self._command_args)
124143
cmd = [
125144
"ansible-playbook",

src/molecule/command/list.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from molecule.click_cfg import click_command_ex, options
3232
from molecule.command import base
3333
from molecule.console import console
34-
from molecule.constants import MOLECULE_GLOB
34+
from molecule.exceptions import ScenarioFailureError
3535
from molecule.status import Status
3636

3737

@@ -78,8 +78,13 @@ def list_(ctx: click.Context) -> None: # pragma: no cover
7878
scenario_name = ctx.params["scenario_name"]
7979

8080
statuses = []
81+
try:
82+
configs = base.get_configs(args, command_args)
83+
except ScenarioFailureError as exc:
84+
util.sysexit(code=exc.code)
85+
8186
s = scenarios.Scenarios(
82-
base.get_configs(args, command_args, glob_str=MOLECULE_GLOB),
87+
configs,
8388
None if scenario_name is None else [scenario_name],
8489
)
8590
for scenario in s:

src/molecule/config.py

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -248,22 +248,10 @@ def collection_directory(self) -> Path | None:
248248
"""Location of collection containing the molecule files.
249249
250250
Returns:
251-
Root of the collection containing the molecule files.
251+
Root of the collection containing the molecule files, only if galaxy.yml is valid.
252252
"""
253-
test_paths = [Path.cwd(), Path(self.project_directory)]
254-
255-
for path in test_paths:
256-
if (path / "galaxy.yml").exists():
257-
return path
258-
259-
# Last resort, try to find git root
260-
show_toplevel = self.app.run_command("git rev-parse --show-toplevel")
261-
if show_toplevel.returncode == 0:
262-
path = Path(show_toplevel.stdout.strip())
263-
if (path / "galaxy.yml").exists():
264-
return path
265-
266-
return None
253+
collection_dir, collection_data = util.get_collection_metadata()
254+
return collection_dir if collection_data else None
267255

268256
@property
269257
def molecule_directory(self) -> str:
@@ -280,24 +268,10 @@ def collection(self) -> CollectionData | None:
280268
281269
Returns:
282270
A dictionary of information about the collection molecule is running inside, if any.
271+
Only returns collection data when galaxy.yml is valid with required fields.
283272
"""
284-
collection_directory = self.collection_directory
285-
if not collection_directory:
286-
return None
287-
288-
galaxy_file = collection_directory / "galaxy.yml"
289-
galaxy_data: CollectionData = util.safe_load_file(galaxy_file)
290-
291-
important_keys = {"name", "namespace"}
292-
if missing_keys := important_keys.difference(galaxy_data.keys()):
293-
self._log.warning(
294-
"The detected galaxy.yml file (%s) is invalid, missing mandatory field %s",
295-
galaxy_file,
296-
util.oxford_comma(missing_keys),
297-
)
298-
return None # pragma: no cover
299-
300-
return galaxy_data
273+
_collection_directory, collection_data = util.get_collection_metadata()
274+
return collection_data
301275

302276
@cached_property
303277
def dependency(self) -> Dependency | None:

src/molecule/constants.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515

1616
# File and directory patterns
1717
MOLECULE_HEADER = "# Molecule managed"
18-
MOLECULE_GLOB = os.environ.get("MOLECULE_GLOB", "molecule/*/molecule.yml")
18+
MOLECULE_ROOT = "molecule"
19+
MOLECULE_GLOB = f"{MOLECULE_ROOT}/*/molecule.yml"
20+
MOLECULE_COLLECTION_ROOT = "extensions/molecule"
21+
MOLECULE_COLLECTION_GLOB = f"{MOLECULE_COLLECTION_ROOT}/*/molecule.yml"
1922

2023
# Default values
2124
MOLECULE_DEFAULT_SCENARIO_NAME = "default"

src/molecule/shell.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from molecule.click_cfg import click_group_ex
3939
from molecule.config import MOLECULE_DEBUG, MOLECULE_VERBOSITY
4040
from molecule.console import console
41+
from molecule.constants import MOLECULE_COLLECTION_ROOT
4142
from molecule.util import do_report, lookup_config_file
4243

4344

@@ -121,7 +122,8 @@ def print_version(
121122
" and deep merge each scenario's "
122123
"molecule.yml on top. By default Molecule is looking for "
123124
f"'{LOCAL_CONFIG_SEARCH}' "
124-
"in current VCS repository and if not found it will look "
125+
"in current VCS repository, in collections at "
126+
f"'{MOLECULE_COLLECTION_ROOT}/config.yml', and if not found it will look "
125127
f"in user home. ({LOCAL_CONFIG})."
126128
),
127129
)

src/molecule/util.py

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,13 @@
3737
from rich.syntax import Syntax
3838

3939
from molecule.console import console
40-
from molecule.constants import MOLECULE_HEADER
40+
from molecule.constants import (
41+
MOLECULE_COLLECTION_GLOB,
42+
MOLECULE_COLLECTION_ROOT,
43+
MOLECULE_GLOB,
44+
MOLECULE_HEADER,
45+
MOLECULE_ROOT,
46+
)
4147
from molecule.exceptions import MoleculeError
4248

4349

@@ -47,7 +53,7 @@
4753
from typing import Any, AnyStr, NoReturn, TypeVar
4854
from warnings import WarningMessage
4955

50-
from molecule.types import CommandArgs, ConfigData, Options, PlatformData
56+
from molecule.types import CollectionData, CommandArgs, ConfigData, Options, PlatformData
5157

5258
NestedDict = MutableMapping[str, Any]
5359
_T = TypeVar("_T", bound=NestedDict)
@@ -459,16 +465,26 @@ def lookup_config_file(filename: str) -> str | None:
459465
"""Return config file PATH.
460466
461467
Args:
462-
filename: Config file name to find.and
468+
filename: Config file name to find.
463469
464470
Returns:
465471
Path to config file or None if not found.
466472
"""
467-
for path in [find_vcs_root(default="~"), "~"]:
468-
f = (Path(path) / filename).expanduser()
469-
if f.is_file():
470-
LOG.info("Found config file %s", f)
471-
return str(f)
473+
# project root
474+
search_paths = [Path(find_vcs_root(default="~")) / filename]
475+
476+
# collection path
477+
collection_dir, _ = get_collection_metadata()
478+
if collection_dir:
479+
search_paths.append(Path(collection_dir / MOLECULE_COLLECTION_ROOT / Path(filename).name))
480+
481+
# home directory
482+
search_paths.append(Path.home() / filename)
483+
484+
for path in search_paths:
485+
if path.is_file():
486+
LOG.info("Found config file %s", path)
487+
return str(path)
472488
return None
473489

474490

@@ -573,3 +589,74 @@ def oxford_comma(listed: Iterable[bool | str | Path], condition: str = "and") ->
573589
return f"{', '.join(s for s in front)}, {condition} {back}"
574590
case _:
575591
return ""
592+
593+
594+
@cache
595+
def get_collection_metadata() -> tuple[Path, CollectionData] | tuple[None, None]:
596+
"""Get collection directory and metadata.
597+
598+
Returns:
599+
Tuple of (collection_directory, collection_data) if in a valid collection,
600+
or (None, None) if not in collection or galaxy.yml is invalid.
601+
Collection data dict contains at least 'name' and 'namespace' keys.
602+
Only returns collection info when galaxy.yml is valid.
603+
"""
604+
cwd = Path.cwd()
605+
galaxy_file = cwd / "galaxy.yml"
606+
607+
try:
608+
galaxy_data: CollectionData = safe_load_file(galaxy_file)
609+
important_keys = {"name", "namespace"}
610+
611+
if not isinstance(galaxy_data, dict):
612+
LOG.warning("Invalid galaxy.yml format at %s", galaxy_file)
613+
return None, None
614+
615+
if missing_keys := important_keys.difference(galaxy_data.keys()):
616+
LOG.warning(
617+
"galaxy.yml at %s is missing required fields: %s",
618+
galaxy_file,
619+
oxford_comma(missing_keys),
620+
)
621+
return None, None
622+
except FileNotFoundError:
623+
LOG.warning("No galaxy.yml found at %s", galaxy_file)
624+
return None, None
625+
except (OSError, yaml.YAMLError, MoleculeError) as exc:
626+
LOG.warning("Failed to load galaxy.yml at %s: %s", galaxy_file, exc)
627+
return None, None
628+
else:
629+
LOG.debug("Found galaxy.yml at %s", galaxy_file)
630+
return cwd, galaxy_data
631+
632+
633+
@cache
634+
def get_effective_molecule_glob() -> str:
635+
"""Get the appropriate glob pattern.
636+
637+
Returns:
638+
Glob pattern string for finding molecule.yml files.
639+
Returns MOLECULE_COLLECTION_GLOB if in collection,
640+
otherwise returns MOLECULE_GLOB.
641+
"""
642+
# User provided
643+
if "MOLECULE_GLOB" in os.environ:
644+
return os.environ["MOLECULE_GLOB"]
645+
646+
# No collection detected
647+
collection_dir, collection_data = get_collection_metadata()
648+
if not collection_dir or not collection_data:
649+
return MOLECULE_GLOB
650+
651+
# Molecule found in root of collection
652+
if (Path.cwd() / MOLECULE_ROOT).exists():
653+
msg = f"Molecule scenarios should migrate to '{MOLECULE_COLLECTION_ROOT}'"
654+
LOG.warning(msg)
655+
return MOLECULE_GLOB
656+
657+
# Molecule not found in collection use extensions
658+
msg = f"Collection '{collection_data['name']}.{collection_data['namespace']}' detected."
659+
LOG.info(msg)
660+
msg = f"Scenarios will be used from '{MOLECULE_COLLECTION_ROOT}'"
661+
LOG.info(msg)
662+
return MOLECULE_COLLECTION_GLOB

tests/fixtures/resources/sample-collection/molecule/default/converge.yml renamed to tests/fixtures/resources/sample-collection/extensions/molecule/default/converge.yml

File renamed without changes.

tests/fixtures/resources/sample-collection/molecule/default/molecule.yml renamed to tests/fixtures/resources/sample-collection/extensions/molecule/default/molecule.yml

File renamed without changes.

0 commit comments

Comments
 (0)