diff --git a/src/molecule/command/base.py b/src/molecule/command/base.py index a45bac4e6..14e38db68 100644 --- a/src/molecule/command/base.py +++ b/src/molecule/command/base.py @@ -99,10 +99,11 @@ def _setup(self) -> None: def execute_cmdline_scenarios( - scenario_name: str | None, + scenario_names: list[str] | None, args: MoleculeArgs, command_args: CommandArgs, ansible_args: tuple[str, ...] = (), + excludes: list[str] | None = None, ) -> None: """Execute scenario sequences based on parsed command-line arguments. @@ -113,28 +114,33 @@ def execute_cmdline_scenarios( to generate the scenario(s) configuration. Args: - scenario_name: Name of scenario to run, or ``None`` to run all. + scenario_names: Name of scenarios to run, or ``None`` to run all. args: ``args`` dict from ``click`` command context command_args: dict of command arguments, including the target ansible_args: Optional tuple of arguments to pass to the `ansible-playbook` command + excludes: Name of scenarios to not run. Raises: SystemExit: If scenario exits prematurely. """ - glob_str = MOLECULE_GLOB - if scenario_name: - glob_str = glob_str.replace("*", scenario_name) - scenarios = molecule.scenarios.Scenarios( - get_configs(args, command_args, ansible_args, glob_str), - scenario_name, - ) + if excludes is None: + excludes = [] + + configs: list[config.Config] = [] + if scenario_names is None: + configs = [ + config + for config in get_configs(args, command_args, ansible_args, MOLECULE_GLOB) + if config.scenario.name not in excludes + ] + else: + # filter out excludes + scenario_names = [name for name in scenario_names if name not in excludes] + for scenario_name in scenario_names: + glob_str = MOLECULE_GLOB.replace("*", scenario_name) + configs.extend(get_configs(args, command_args, ansible_args, glob_str)) - if scenario_name and scenarios: - LOG.info( - "%s scenario test matrix: %s", - scenario_name, - ", ".join(scenarios.sequence(scenario_name)), - ) + scenarios = _generate_scenarios(scenario_names, configs) for scenario in scenarios: if scenario.config.config["prerun"]: @@ -171,6 +177,36 @@ def execute_cmdline_scenarios( raise +def _generate_scenarios( + scenario_names: list[str] | None, + configs: list[config.Config], +) -> molecule.scenarios.Scenarios: + """Generate Scenarios object from names and configs. + + Args: + scenario_names: Names of scenarios to include. + configs: List of Config objects to consider. + + Returns: + Combined Scenarios object. + """ + scenarios = molecule.scenarios.Scenarios( + configs, + scenario_names, + ) + + if scenario_names is not None: + for scenario_name in scenario_names: + if scenario_name != "*" and scenarios: + LOG.info( + "%s scenario test matrix: %s", + scenario_name, + ", ".join(scenarios.sequence(scenario_name)), + ) + + return scenarios + + def execute_subcommand( current_config: config.Config, subcommand_and_args: str, diff --git a/src/molecule/command/check.py b/src/molecule/command/check.py index c879ca081..a58a190b4 100644 --- a/src/molecule/command/check.py +++ b/src/molecule/command/check.py @@ -57,8 +57,21 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 @click.option( "--scenario-name", "-s", - default=base.MOLECULE_DEFAULT_SCENARIO_NAME, - help=f"Name of the scenario to target. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", + multiple=True, + default=[base.MOLECULE_DEFAULT_SCENARIO_NAME], + help=f"Name of the scenario to target. May be specified multiple times. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", +) +@click.option( + "--all/--no-all", + "__all", + default=False, + help="Check all scenarios. Default is False.", +) +@click.option( + "--exclude", + "-e", + multiple=True, + help="Name of the scenario to exclude from running. May be specified multiple times.", ) @click.option( "--parallel/--no-parallel", @@ -67,7 +80,9 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 ) def check( # pragma: no cover ctx: click.Context, - scenario_name: str, + scenario_name: list[str] | None, + exclude: list[str], + __all: bool, # noqa: FBT001 *, parallel: bool, ) -> None: @@ -76,13 +91,18 @@ def check( # pragma: no cover Args: ctx: Click context object holding commandline arguments. scenario_name: Name of the scenario to target. + exclude: Name of the scenarios to avoid targeting. + __all: Whether molecule should target scenario_name or all scenarios. parallel: Whether the scenario(s) should be run in parallel. """ args: MoleculeArgs = ctx.obj.get("args") subcommand = base._get_subcommand(__name__) # noqa: SLF001 command_args: CommandArgs = {"parallel": parallel, "subcommand": subcommand} + if __all: + scenario_name = None + if parallel: util.validate_parallel_cmd_args(command_args) - base.execute_cmdline_scenarios(scenario_name, args, command_args) + base.execute_cmdline_scenarios(scenario_name, args, command_args, excludes=exclude) diff --git a/src/molecule/command/cleanup.py b/src/molecule/command/cleanup.py index 4c1241bed..0c6b7cdbe 100644 --- a/src/molecule/command/cleanup.py +++ b/src/molecule/command/cleanup.py @@ -59,12 +59,27 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 @click.option( "--scenario-name", "-s", - default=base.MOLECULE_DEFAULT_SCENARIO_NAME, - help=f"Name of the scenario to target. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", + multiple=True, + default=[base.MOLECULE_DEFAULT_SCENARIO_NAME], + help=f"Name of the scenario to target. May be specified multiple times. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", +) +@click.option( + "--all/--no-all", + "__all", + default=False, + help="Cleanup all scenarios. Default is False.", +) +@click.option( + "--exclude", + "-e", + multiple=True, + help="Name of the scenario to exclude from running. May be specified multiple times.", ) def cleanup( ctx: click.Context, - scenario_name: str = "default", + scenario_name: list[str] | None, + exclude: list[str], + __all: bool, # noqa: FBT001 ) -> None: # pragma: no cover """Use the provisioner to cleanup any changes. @@ -73,9 +88,14 @@ def cleanup( Args: ctx: Click context object holding commandline arguments. scenario_name: Name of the scenario to target. + exclude: Name of the scenarios to avoid targeting. + __all: Whether molecule should target scenario_name or all scenarios. """ args: MoleculeArgs = ctx.obj.get("args") subcommand = base._get_subcommand(__name__) # noqa: SLF001 command_args: CommandArgs = {"subcommand": subcommand} - base.execute_cmdline_scenarios(scenario_name, args, command_args) + if __all: + scenario_name = None + + base.execute_cmdline_scenarios(scenario_name, args, command_args, excludes=exclude) diff --git a/src/molecule/command/converge.py b/src/molecule/command/converge.py index 7526b5d14..5100f27cd 100644 --- a/src/molecule/command/converge.py +++ b/src/molecule/command/converge.py @@ -55,13 +55,28 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 @click.option( "--scenario-name", "-s", - default=base.MOLECULE_DEFAULT_SCENARIO_NAME, - help=f"Name of the scenario to target. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", + multiple=True, + default=[base.MOLECULE_DEFAULT_SCENARIO_NAME], + help=f"Name of the scenario to target. May be specified multiple times. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", +) +@click.option( + "--all/--no-all", + "__all", + default=False, + help="Converge all scenarios. Default is False.", +) +@click.option( + "--exclude", + "-e", + multiple=True, + help="Name of the scenario to exclude from running. May be specified multiple times.", ) @click.argument("ansible_args", nargs=-1, type=click.UNPROCESSED) def converge( ctx: click.Context, - scenario_name: str, + scenario_name: list[str] | None, + exclude: list[str], + __all: bool, # noqa: FBT001 ansible_args: tuple[str], ) -> None: # pragma: no cover """Use the provisioner to configure instances (dependency, create, prepare converge). @@ -69,10 +84,15 @@ def converge( Args: ctx: Click context object holding commandline arguments. scenario_name: Name of the scenario to target. + exclude: Name of the scenarios to avoid targeting. + __all: Whether molecule should target scenario_name or all scenarios. ansible_args: Arguments to forward to Ansible. """ args: MoleculeArgs = ctx.obj.get("args") subcommand = base._get_subcommand(__name__) # noqa: SLF001 command_args: CommandArgs = {"subcommand": subcommand} - base.execute_cmdline_scenarios(scenario_name, args, command_args, ansible_args) + if __all: + scenario_name = None + + base.execute_cmdline_scenarios(scenario_name, args, command_args, ansible_args, exclude) diff --git a/src/molecule/command/create.py b/src/molecule/command/create.py index 5f15cbf34..7e80c8200 100644 --- a/src/molecule/command/create.py +++ b/src/molecule/command/create.py @@ -65,8 +65,9 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 @click.option( "--scenario-name", "-s", - default=base.MOLECULE_DEFAULT_SCENARIO_NAME, - help=f"Name of the scenario to target. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", + multiple=True, + default=[base.MOLECULE_DEFAULT_SCENARIO_NAME], + help=f"Name of the scenario to target. May be specified multiple times. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", ) @click.option( "--driver-name", @@ -74,20 +75,39 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 type=click.Choice([str(s) for s in drivers()]), help=f"Name of driver to use. ({DEFAULT_DRIVER})", ) +@click.option( + "--all/--no-all", + "__all", + default=False, + help="Start all scenarios. Default is False.", +) +@click.option( + "--exclude", + "-e", + multiple=True, + help="Name of the scenario to exclude from running. May be specified multiple times.", +) def create( ctx: click.Context, - scenario_name: str, + scenario_name: list[str] | None, + exclude: list[str], driver_name: str, + __all: bool, # noqa: FBT001 ) -> None: # pragma: no cover """Use the provisioner to start the instances. Args: ctx: Click context object holding commandline arguments. scenario_name: Name of the scenario to target. + exclude: Name of the scenarios to avoid targeting. driver_name: Name of the Molecule driver to use. + __all: Whether molecule should target scenario_name or all scenarios. """ args: MoleculeArgs = ctx.obj.get("args") subcommand = base._get_subcommand(__name__) # noqa: SLF001 command_args: CommandArgs = {"subcommand": subcommand, "driver_name": driver_name} - base.execute_cmdline_scenarios(scenario_name, args, command_args) + if __all: + scenario_name = None + + base.execute_cmdline_scenarios(scenario_name, args, command_args, excludes=exclude) diff --git a/src/molecule/command/dependency.py b/src/molecule/command/dependency.py index 43ffe2d5c..236952019 100644 --- a/src/molecule/command/dependency.py +++ b/src/molecule/command/dependency.py @@ -54,21 +54,41 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 @click.option( "--scenario-name", "-s", - default=base.MOLECULE_DEFAULT_SCENARIO_NAME, - help=f"Name of the scenario to target. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", + multiple=True, + default=[base.MOLECULE_DEFAULT_SCENARIO_NAME], + help=f"Name of the scenario to target. May be specified multiple times. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", +) +@click.option( + "--all/--no-all", + "__all", + default=False, + help="Target all scenarios. Default is False.", +) +@click.option( + "--exclude", + "-e", + multiple=True, + help="Name of the scenario to exclude from running. May be specified multiple times.", ) def dependency( ctx: click.Context, - scenario_name: str, + scenario_name: list[str] | None, + exclude: list[str], + __all: bool, # noqa: FBT001 ) -> None: # pragma: no cover """Manage the role's dependencies. Args: ctx: Click context object holding commandline arguments. scenario_name: Name of the scenario to target. + exclude: Name of the scenarios to avoid targeting. + __all: Whether molecule should target scenario_name or all scenarios. """ args: MoleculeArgs = ctx.obj.get("args") subcommand = base._get_subcommand(__name__) # noqa: SLF001 command_args: CommandArgs = {"subcommand": subcommand} - base.execute_cmdline_scenarios(scenario_name, args, command_args) + if __all: + scenario_name = None + + base.execute_cmdline_scenarios(scenario_name, args, command_args, excludes=exclude) diff --git a/src/molecule/command/destroy.py b/src/molecule/command/destroy.py index 438a06eb4..91c2d928a 100644 --- a/src/molecule/command/destroy.py +++ b/src/molecule/command/destroy.py @@ -65,8 +65,9 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 @click.option( "--scenario-name", "-s", - default=base.MOLECULE_DEFAULT_SCENARIO_NAME, - help=f"Name of the scenario to target. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", + multiple=True, + default=[base.MOLECULE_DEFAULT_SCENARIO_NAME], + help=f"Name of the scenario to target. May be specified multiple times. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", ) @click.option( "--driver-name", @@ -80,6 +81,12 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 default=MOLECULE_PARALLEL, help="Destroy all scenarios. Default is False.", ) +@click.option( + "--exclude", + "-e", + multiple=True, + help="Name of the scenario to exclude from running. May be specified multiple times.", +) @click.option( "--parallel/--no-parallel", default=False, @@ -87,7 +94,8 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 ) def destroy( ctx: click.Context, - scenario_name: str | None, + scenario_name: list[str] | None, + exclude: list[str], driver_name: str, __all: bool, # noqa: FBT001 parallel: bool, # noqa: FBT001 @@ -97,6 +105,7 @@ def destroy( Args: ctx: Click context object holding commandline arguments. scenario_name: Name of the scenario to target. + exclude: Name of the scenarios to avoid targeting. driver_name: Molecule driver to use. __all: Whether molecule should target scenario_name or all scenarios. parallel: Whether the scenario(s) should be run in parallel mode. @@ -115,4 +124,4 @@ def destroy( if parallel: util.validate_parallel_cmd_args(command_args) - base.execute_cmdline_scenarios(scenario_name, args, command_args) + base.execute_cmdline_scenarios(scenario_name, args, command_args, excludes=exclude) diff --git a/src/molecule/command/idempotence.py b/src/molecule/command/idempotence.py index d8f0e9a23..4005d87c4 100644 --- a/src/molecule/command/idempotence.py +++ b/src/molecule/command/idempotence.py @@ -121,13 +121,28 @@ def _non_idempotent_tasks(self, output: str) -> list[str]: @click.option( "--scenario-name", "-s", - default=base.MOLECULE_DEFAULT_SCENARIO_NAME, - help=f"Name of the scenario to target. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", + multiple=True, + default=[base.MOLECULE_DEFAULT_SCENARIO_NAME], + help=f"Name of the scenario to target. May be specified multiple times. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", +) +@click.option( + "--all/--no-all", + "__all", + default=False, + help="Target all scenarios. Default is False.", +) +@click.option( + "--exclude", + "-e", + multiple=True, + help="Name of the scenario to exclude from running. May be specified multiple times.", ) @click.argument("ansible_args", nargs=-1, type=click.UNPROCESSED) def idempotence( ctx: click.Context, - scenario_name: str, + scenario_name: list[str] | None, + exclude: list[str], + __all: bool, # noqa: FBT001 ansible_args: tuple[str, ...], ) -> None: # pragma: no cover """Use the provisioner to configure the instances. @@ -137,10 +152,15 @@ def idempotence( Args: ctx: Click context object holding commandline arguments. scenario_name: Name of the scenario to target. + exclude: Name of the scenarios to avoid targeting. + __all: Whether molecule should target scenario_name or all scenarios. ansible_args: Arguments to forward to Ansible. """ args: MoleculeArgs = ctx.obj.get("args") subcommand = base._get_subcommand(__name__) # noqa: SLF001 command_args: CommandArgs = {"subcommand": subcommand} - base.execute_cmdline_scenarios(scenario_name, args, command_args, ansible_args) + if __all: + scenario_name = None + + base.execute_cmdline_scenarios(scenario_name, args, command_args, ansible_args, exclude) diff --git a/src/molecule/command/list.py b/src/molecule/command/list.py index 89027e526..001ca8731 100644 --- a/src/molecule/command/list.py +++ b/src/molecule/command/list.py @@ -91,7 +91,7 @@ def list_( statuses = [] s = scenarios.Scenarios( base.get_configs(args, command_args, glob_str=MOLECULE_GLOB), - scenario_name, + None if scenario_name is None else [scenario_name], ) for scenario in s: statuses.extend(base.execute_subcommand(scenario.config, subcommand)) diff --git a/src/molecule/command/login.py b/src/molecule/command/login.py index 3803d850d..c3ab08075 100644 --- a/src/molecule/command/login.py +++ b/src/molecule/command/login.py @@ -146,6 +146,6 @@ def login( subcommand = base._get_subcommand(__name__) # noqa: SLF001 command_args: CommandArgs = {"subcommand": subcommand, "host": host} - s = scenarios.Scenarios(base.get_configs(args, command_args), scenario_name) + s = scenarios.Scenarios(base.get_configs(args, command_args), [scenario_name]) for scenario in s.all: base.execute_subcommand(scenario.config, subcommand) diff --git a/src/molecule/command/matrix.py b/src/molecule/command/matrix.py index febab0ee4..f56c18395 100644 --- a/src/molecule/command/matrix.py +++ b/src/molecule/command/matrix.py @@ -94,5 +94,5 @@ def matrix( args: MoleculeArgs = ctx.obj.get("args") command_args: CommandArgs = {"subcommand": subcommand} - s = scenarios.Scenarios(base.get_configs(args, command_args), scenario_name) + s = scenarios.Scenarios(base.get_configs(args, command_args), [scenario_name]) s.print_matrix() diff --git a/src/molecule/command/prepare.py b/src/molecule/command/prepare.py index 2047bc578..de6f95fdc 100644 --- a/src/molecule/command/prepare.py +++ b/src/molecule/command/prepare.py @@ -117,8 +117,9 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 @click.option( "--scenario-name", "-s", - default=base.MOLECULE_DEFAULT_SCENARIO_NAME, - help=f"Name of the scenario to target. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", + multiple=True, + default=[base.MOLECULE_DEFAULT_SCENARIO_NAME], + help=f"Name of the scenario to target. May be specified multiple times. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", ) @click.option( "--driver-name", @@ -126,6 +127,18 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 type=click.Choice([str(s) for s in drivers()]), help=f"Name of driver to use. ({DEFAULT_DRIVER})", ) +@click.option( + "--all/--no-all", + "__all", + default=False, + help="Prepare all scenarios. Default is False.", +) +@click.option( + "--exclude", + "-e", + multiple=True, + help="Name of the scenario to exclude from running. May be specified multiple times.", +) @click.option( "--force/--no-force", "-f", @@ -134,8 +147,10 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 ) def prepare( ctx: click.Context, - scenario_name: str, + scenario_name: list[str] | None, + exclude: list[str], driver_name: str, + __all: bool, # noqa: FBT001 force: bool, # noqa: FBT001 ) -> None: # pragma: no cover """Use the provisioner to prepare the instances into a particular starting state. @@ -143,7 +158,9 @@ def prepare( Args: ctx: Click context object holding commandline arguments. scenario_name: Name of the scenario to target. + exclude: Name of the scenarios to avoid targeting. driver_name: Name of the Molecule driver to use. + __all: Whether molecule should target scenario_name or all scenarios. force: Whether to use force mode. """ args: MoleculeArgs = ctx.obj.get("args") @@ -154,4 +171,7 @@ def prepare( "force": force, } - base.execute_cmdline_scenarios(scenario_name, args, command_args) + if __all: + scenario_name = None + + base.execute_cmdline_scenarios(scenario_name, args, command_args, excludes=exclude) diff --git a/src/molecule/command/reset.py b/src/molecule/command/reset.py index 1e7b587fd..bec6721dc 100644 --- a/src/molecule/command/reset.py +++ b/src/molecule/command/reset.py @@ -59,6 +59,6 @@ def reset( subcommand = base._get_subcommand(__name__) # noqa: SLF001 command_args: CommandArgs = {"subcommand": subcommand} - base.execute_cmdline_scenarios(scenario_name, args, command_args) + base.execute_cmdline_scenarios([scenario_name], args, command_args) for driver in drivers().values(): driver.reset() diff --git a/src/molecule/command/side_effect.py b/src/molecule/command/side_effect.py index 6cf50ffb5..c757af864 100644 --- a/src/molecule/command/side_effect.py +++ b/src/molecule/command/side_effect.py @@ -62,21 +62,41 @@ def execute(self, action_args: list[str] | None = None) -> None: @click.option( "--scenario-name", "-s", - default=base.MOLECULE_DEFAULT_SCENARIO_NAME, - help=f"Name of the scenario to target. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", + multiple=True, + default=[base.MOLECULE_DEFAULT_SCENARIO_NAME], + help=f"Name of the scenario to target. May be specified multiple times. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", +) +@click.option( + "--all/--no-all", + "__all", + default=False, + help="Target all scenarios. Default is False.", +) +@click.option( + "--exclude", + "-e", + multiple=True, + help="Name of the scenario to exclude from running. May be specified multiple times.", ) def side_effect( ctx: click.Context, - scenario_name: str, + scenario_name: list[str] | None, + exclude: list[str], + __all: bool, # noqa: FBT001 ) -> None: # pragma: no cover """Use the provisioner to perform side-effects to the instances. Args: ctx: Click context object holding commandline arguments. scenario_name: Name of the scenario to target. + exclude: Name of the scenarios to avoid targeting. + __all: Whether molecule should target scenario_name or all scenarios. """ args = ctx.obj.get("args") subcommand = base._get_subcommand(__name__) # noqa: SLF001 command_args: CommandArgs = {"subcommand": subcommand} - base.execute_cmdline_scenarios(scenario_name, args, command_args) + if __all: + scenario_name = None + + base.execute_cmdline_scenarios(scenario_name, args, command_args, excludes=exclude) diff --git a/src/molecule/command/syntax.py b/src/molecule/command/syntax.py index 8f33572f5..8465c0710 100644 --- a/src/molecule/command/syntax.py +++ b/src/molecule/command/syntax.py @@ -54,21 +54,41 @@ def execute(self, action_args: list[str] | None = None) -> None: # noqa: ARG002 @click.option( "--scenario-name", "-s", - default=base.MOLECULE_DEFAULT_SCENARIO_NAME, - help=f"Name of the scenario to target. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", + multiple=True, + default=[base.MOLECULE_DEFAULT_SCENARIO_NAME], + help=f"Name of the scenario to target. May be specified multiple times. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", +) +@click.option( + "--all/--no-all", + "__all", + default=False, + help="Syntax check all scenarios. Default is False.", +) +@click.option( + "--exclude", + "-e", + multiple=True, + help="Name of the scenario to exclude from running. May be specified multiple times.", ) def syntax( ctx: click.Context, - scenario_name: str, + scenario_name: list[str] | None, + exclude: list[str], + __all: bool, # noqa: FBT001 ) -> None: # pragma: no cover """Use the provisioner to syntax check the role. Args: ctx: Click context object holding commandline arguments. scenario_name: Name of the scenario to target. + exclude: Name of the scenarios to avoid targeting. + __all: Whether molecule should target scenario_name or all scenarios. """ args: MoleculeArgs = ctx.obj.get("args") subcommand = base._get_subcommand(__name__) # noqa: SLF001 command_args: CommandArgs = {"subcommand": subcommand} - base.execute_cmdline_scenarios(scenario_name, args, command_args) + if __all: + scenario_name = None + + base.execute_cmdline_scenarios(scenario_name, args, command_args, excludes=exclude) diff --git a/src/molecule/command/test.py b/src/molecule/command/test.py index 34d0bc51c..be3ef20f1 100644 --- a/src/molecule/command/test.py +++ b/src/molecule/command/test.py @@ -60,8 +60,9 @@ def execute(self, action_args: list[str] | None = None) -> None: @click.option( "--scenario-name", "-s", - default=base.MOLECULE_DEFAULT_SCENARIO_NAME, - help=f"Name of the scenario to target. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", + multiple=True, + default=[base.MOLECULE_DEFAULT_SCENARIO_NAME], + help=f"Name of the scenario to target. May be specified multiple times. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", ) @click.option( "--platform-name", @@ -81,6 +82,12 @@ def execute(self, action_args: list[str] | None = None) -> None: default=False, help="Test all scenarios. Default is False.", ) +@click.option( + "--exclude", + "-e", + multiple=True, + help="Name of the scenario to exclude from running. May be specified multiple times.", +) @click.option( "--destroy", type=click.Choice(["always", "never"]), @@ -95,7 +102,8 @@ def execute(self, action_args: list[str] | None = None) -> None: @click.argument("ansible_args", nargs=-1, type=click.UNPROCESSED) def test( # noqa: PLR0913 ctx: click.Context, - scenario_name: str | None, + scenario_name: list[str] | None, + exclude: list[str], driver_name: str, __all: bool, # noqa: FBT001 destroy: Literal["always", "never"], @@ -108,6 +116,7 @@ def test( # noqa: PLR0913 Args: ctx: Click context object holding commandline arguments. scenario_name: Name of the scenario to target. + exclude: Name of the scenarios to avoid targeting. driver_name: Name of the driver to use. __all: Whether molecule should target scenario_name or all scenarios. destroy: The destroy strategy to use. @@ -131,4 +140,4 @@ def test( # noqa: PLR0913 if parallel: util.validate_parallel_cmd_args(command_args) - base.execute_cmdline_scenarios(scenario_name, args, command_args, ansible_args) + base.execute_cmdline_scenarios(scenario_name, args, command_args, ansible_args, exclude) diff --git a/src/molecule/command/verify.py b/src/molecule/command/verify.py index 199cdf9ed..659176790 100644 --- a/src/molecule/command/verify.py +++ b/src/molecule/command/verify.py @@ -53,21 +53,41 @@ def execute(self, action_args: list[str] | None = None) -> None: @click.option( "--scenario-name", "-s", - default=base.MOLECULE_DEFAULT_SCENARIO_NAME, - help=f"Name of the scenario to target. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", + multiple=True, + default=[base.MOLECULE_DEFAULT_SCENARIO_NAME], + help=f"Name of the scenario to target. May be specified multiple times. ({base.MOLECULE_DEFAULT_SCENARIO_NAME})", +) +@click.option( + "--all/--no-all", + "__all", + default=False, + help="Verify all scenarios. Default is False.", +) +@click.option( + "--exclude", + "-e", + multiple=True, + help="Name of the scenario to exclude from running. May be specified multiple times.", ) def verify( ctx: click.Context, - scenario_name: str = "default", + scenario_name: list[str] | None, + exclude: list[str], + __all: bool, # noqa: FBT001 ) -> None: # pragma: no cover """Run automated tests against instances. Args: ctx: Click context object holding commandline arguments. scenario_name: Name of the scenario to target. + exclude: Name of the scenarios to avoid targeting. + __all: Whether molecule should target scenario_name or all scenarios. """ args: MoleculeArgs = ctx.obj.get("args") subcommand = base._get_subcommand(__name__) # noqa: SLF001 command_args: CommandArgs = {"subcommand": subcommand} - base.execute_cmdline_scenarios(scenario_name, args, command_args) + if __all: + scenario_name = None + + base.execute_cmdline_scenarios(scenario_name, args, command_args, excludes=exclude) diff --git a/src/molecule/scenarios.py b/src/molecule/scenarios.py index 7fa18f8c3..a3c0d03dc 100644 --- a/src/molecule/scenarios.py +++ b/src/molecule/scenarios.py @@ -41,16 +41,16 @@ class Scenarios: def __init__( self, configs: list[Config], - scenario_name: str | None = None, + scenario_names: list[str] | None = None, ) -> None: """Initialize a new scenarios class and returns None. Args: configs: Molecule config instances. - scenario_name: The name of the scenario. + scenario_names: The names of the scenarios. """ self._configs = configs - self._scenario_name = scenario_name + self._scenario_names = [] if scenario_names is None else scenario_names self._scenarios = self.all def __iter__(self) -> Scenarios: @@ -81,7 +81,7 @@ def all(self) -> list[Scenario]: Returns: All Scenario objects. """ - if self._scenario_name: + if self._scenario_names: scenarios = self._filter_for_scenario() self._verify() @@ -123,8 +123,12 @@ def sequence(self, scenario_name: str) -> list[str]: def _verify(self) -> None: """Verify the specified scenario was found.""" scenario_names = [c.scenario.name for c in self._configs] - if self._scenario_name not in scenario_names: - msg = f"Scenario '{self._scenario_name}' not found. Exiting." + if missing_names := sorted(set(self._scenario_names).difference(scenario_names)): + scenario = "Scenario" + if len(missing_names) > 1: + scenario += "s" + missing = ", ".join(missing_names) + msg = f"{scenario} '{missing}' not found. Exiting." util.sysexit_with_message(msg) def _filter_for_scenario(self) -> list[Scenario]: @@ -133,7 +137,7 @@ def _filter_for_scenario(self) -> list[Scenario]: Returns: list """ - return [c.scenario for c in self._configs if c.scenario.name == self._scenario_name] + return [c.scenario for c in self._configs if c.scenario.name in self._scenario_names] def _get_matrix(self) -> dict[str, dict[str, list[str]]]: """Build a matrix of scenarios and step sequences. diff --git a/tests/unit/command/test_base.py b/tests/unit/command/test_base.py index 0955f3dbf..8d2e65117 100644 --- a/tests/unit/command/test_base.py +++ b/tests/unit/command/test_base.py @@ -252,7 +252,7 @@ def test_execute_cmdline_scenarios_prune( patched_execute_subcommand: Mocked execute_subcommand function. patched_prune: Mocked prune function. """ - scenario_name = "default" + scenario_name = ["default"] args: MoleculeArgs = {} command_args: CommandArgs = {"destroy": "always", "subcommand": "test"} @@ -273,7 +273,7 @@ def test_execute_cmdline_scenarios_no_prune( patched_prune: Mocked prune function. patched_execute_subcommand: Mocked execute_subcommand function. """ - scenario_name = "default" + scenario_name = ["default"] args: MoleculeArgs = {} command_args: CommandArgs = {"destroy": "never", "subcommand": "test"} @@ -301,7 +301,7 @@ def test_execute_cmdline_scenarios_exit_destroy( patched_execute_subcommand: Mocked execute_subcommand function. patched_sysexit: Mocked util.sysexit function. """ - scenario_name = "default" + scenario_name = ["default"] args: MoleculeArgs = {} command_args: CommandArgs = {"destroy": "always", "subcommand": "test"} patched_execute_scenario.side_effect = SystemExit() @@ -335,7 +335,7 @@ def test_execute_cmdline_scenarios_exit_nodestroy( patched_prune: Mocked prune function. patched_sysexit: Mocked util.sysexit function. """ - scenario_name = "default" + scenario_name = ["default"] args: MoleculeArgs = {} command_args: CommandArgs = {"destroy": "never", "subcommand": "test"} diff --git a/tests/unit/test_scenarios.py b/tests/unit/test_scenarios.py index 9d135dd6f..00cc8f2f4 100644 --- a/tests/unit/test_scenarios.py +++ b/tests/unit/test_scenarios.py @@ -49,7 +49,7 @@ def test_configs_private_member( # noqa: D103 def test_scenario_name_private_member( # noqa: D103 _instance: scenarios.Scenarios, # noqa: PT019 ) -> None: - assert _instance._scenario_name is None + assert _instance._scenario_names == [] def test_scenarios_private_member( # noqa: D103 @@ -80,7 +80,7 @@ def test_all_property(_instance: scenarios.Scenarios) -> None: # noqa: PT019, D def test_all_filters_on_scenario_name_property( # noqa: D103 _instance: scenarios.Scenarios, # noqa: PT019 ) -> None: - _instance._scenario_name = "default" + _instance._scenario_names = ["default"] assert len(_instance.all) == 1 @@ -126,7 +126,7 @@ def test_print_matrix( # noqa: D103 def test_verify_does_not_raise_when_found( # noqa: D103 _instance: scenarios.Scenarios, # noqa: PT019 ) -> None: - _instance._scenario_name = "default" + _instance._scenario_names = ["default"] _instance._verify() @@ -135,7 +135,7 @@ def test_verify_raises_when_scenario_not_found( # noqa: D103 _instance: scenarios.Scenarios, # noqa: PT019 caplog: pytest.LogCaptureFixture, ) -> None: - _instance._scenario_name = "invalid" + _instance._scenario_names = ["invalid"] with pytest.raises(SystemExit) as e: _instance._verify() @@ -145,15 +145,29 @@ def test_verify_raises_when_scenario_not_found( # noqa: D103 assert msg in caplog.text +def test_verify_raises_when_multiple_scenarios_not_found( # noqa: D103 + _instance: scenarios.Scenarios, # noqa: PT019 + caplog: pytest.LogCaptureFixture, +) -> None: + _instance._scenario_names = ["invalid", "also invalid"] + with pytest.raises(SystemExit) as e: + _instance._verify() + + assert e.value.code == 1 + + msg = "Scenarios 'also invalid, invalid' not found. Exiting." + assert msg in caplog.text + + def test_filter_for_scenario( # noqa: D103 _instance: scenarios.Scenarios, # noqa: PT019 ) -> None: - _instance._scenario_name = "default" + _instance._scenario_names = ["default"] result = _instance._filter_for_scenario() assert len(result) == 1 assert result[0].name == "default" - _instance._scenario_name = "invalid" + _instance._scenario_names = ["invalid"] result = _instance._filter_for_scenario() assert result == []