diff --git a/tests/commands/run_command/test_run_command.py b/tests/commands/run_command/test_run_command.py index 6269b29..77d28a8 100644 --- a/tests/commands/run_command/test_run_command.py +++ b/tests/commands/run_command/test_run_command.py @@ -1,5 +1,6 @@ from pathlib import Path +import pytest from baby_steps import given, then, when from pytest import raises @@ -30,6 +31,20 @@ async def test_run_command_without_scenarios(arg_parser: ArgumentParser): with when, raises(BaseException) as exc: await command.run() + with then: + assert exc.type is FileNotFoundError + assert "default_scenarios_dir" in str(exc.value) + assert "does not exist" in str(exc.value) + + +@pytest.mark.usefixtures(tmp_dir.__name__) +async def test_run_command_with_no_scenarios(arg_parser: ArgumentParser): + with given: + command = RunCommand(CustomConfig, arg_parser) + + with when, raises(BaseException) as exc: + await command.run() + with then: assert exc.type is SystemExit assert str(exc.value) == "1" @@ -68,6 +83,7 @@ class Terminator(vedro.Config.Plugins.Terminator): assert str(exc.value) == "0" +@pytest.mark.usefixtures(tmp_dir.__name__) async def test_run_command_validate_plugin_error(arg_parser: ArgumentParser): with given: class InvalidConfig(CustomConfig): diff --git a/vedro/commands/_command.py b/vedro/commands/_command.py index 919d826..8697d11 100644 --- a/vedro/commands/_command.py +++ b/vedro/commands/_command.py @@ -9,11 +9,36 @@ class Command(ABC): + """ + Serves as an abstract base class for defining commands. + + Commands are operations that can be executed with a specific configuration + and argument parser. Subclasses must implement the `run` method to define + the behavior of the command. + + :param config: The global configuration instance for the command. + :param arg_parser: The argument parser for parsing command-line options. + :param kwargs: Additional keyword arguments for customization. + """ + def __init__(self, config: Type[Config], arg_parser: CommandArgumentParser, **kwargs: Any) -> None: + """ + Initialize the Command instance with a configuration and argument parser. + + :param config: The global configuration instance. + :param arg_parser: The argument parser for parsing command-line options. + :param kwargs: Additional keyword arguments for customization. + """ self._config = config self._arg_parser = arg_parser @abstractmethod async def run(self) -> None: + """ + Execute the command's logic. + + Subclasses must implement this method to define the specific behavior + of the command when executed. + """ pass diff --git a/vedro/commands/run_command/_run_command.py b/vedro/commands/run_command/_run_command.py index 286f771..2512545 100644 --- a/vedro/commands/run_command/_run_command.py +++ b/vedro/commands/run_command/_run_command.py @@ -23,16 +23,42 @@ class RunCommand(Command): + """ + Represents the "run" command for executing scenarios. + + This command handles the entire lifecycle of scenario execution, including: + - Validating configuration parameters. + - Registering plugins with the dispatcher. + - Parsing command-line arguments. + - Discovering scenarios. + - Scheduling and executing scenarios. + - Dispatching events before and after scenario execution. + + :param config: The global configuration instance. + :param arg_parser: The argument parser for parsing command-line options. + """ + def __init__(self, config: Type[Config], arg_parser: CommandArgumentParser) -> None: """ - Initialize a new instance of the RunCommand class. + Initialize the RunCommand instance. - :param config: Global configuration - :param arg_parser: Argument parser for command-line options + :param config: The global configuration instance. + :param arg_parser: The argument parser for parsing command-line options. """ super().__init__(config, arg_parser) def _validate_config(self) -> None: + """ + Validate the configuration parameters. + + Ensures that the `default_scenarios_dir` is a valid directory within the + project directory. Raises appropriate exceptions if validation fails. + + :raises TypeError: If `default_scenarios_dir` is not a `Path` or `str`. + :raises FileNotFoundError: If `default_scenarios_dir` does not exist. + :raises NotADirectoryError: If `default_scenarios_dir` is not a directory. + :raises ValueError: If `default_scenarios_dir` is not inside the project directory. + """ default_scenarios_dir = self._config.default_scenarios_dir if not isinstance(default_scenarios_dir, (Path, str)): raise TypeError( @@ -63,7 +89,11 @@ async def _register_plugins(self, dispatcher: Dispatcher) -> None: """ Register plugins with the dispatcher. - :param dispatcher: Dispatcher to register plugins with + Iterates through the configuration's `Plugins` section, validates plugin configurations, + and registers enabled plugins with the dispatcher. + + :param dispatcher: The dispatcher to register plugins with. + :raises TypeError: If a plugin is not a subclass of `vedro.core.Plugin`. """ for _, section in self._config.Plugins.items(): if not issubclass(section.plugin, Plugin) or (section.plugin is Plugin): @@ -80,9 +110,12 @@ async def _register_plugins(self, dispatcher: Dispatcher) -> None: def _validate_plugin_config(self, plugin_config: Type[PluginConfig]) -> None: """ - Validate plugin's configuration. + Validate the configuration of a plugin. + + Ensures that the plugin's configuration does not contain unknown attributes. - :param plugin_config: Configuration of the plugin. + :param plugin_config: The configuration of the plugin. + :raises AttributeError: If the plugin configuration contains unknown attributes. """ unknown_attrs = self._get_attrs(plugin_config) - self._get_parent_attrs(plugin_config) if unknown_attrs: @@ -93,19 +126,19 @@ def _validate_plugin_config(self, plugin_config: Type[PluginConfig]) -> None: def _get_attrs(self, cls: type) -> Set[str]: """ - Get the set of attributes for a class. + Retrieve the set of attributes for a class. - :param cls: The class to get attributes for - :return: The set of attribute names for the class + :param cls: The class to retrieve attributes for. + :return: A set of attribute names for the class. """ return set(vars(cls)) def _get_parent_attrs(self, cls: type) -> Set[str]: """ - Recursively get attributes from parent classes. + Recursively retrieve attributes from parent classes. - :param cls: The class to get parent attributes for - :return: The set of attribute names for the parent classes + :param cls: The class to retrieve parent attributes for. + :return: A set of attribute names for the parent classes. """ attrs = set() # `object` (the base for all classes) has no __bases__ @@ -118,7 +151,11 @@ async def _parse_args(self, dispatcher: Dispatcher) -> Namespace: """ Parse command-line arguments and fire corresponding dispatcher events. - :param dispatcher: The dispatcher to fire events + Adds the `--project-dir` argument, fires the `ArgParseEvent`, parses + the arguments, and then fires the `ArgParsedEvent`. + + :param dispatcher: The dispatcher to fire events. + :return: The parsed arguments as a `Namespace` object. """ # Avoid unrecognized arguments error @@ -141,8 +178,12 @@ async def _parse_args(self, dispatcher: Dispatcher) -> Namespace: async def run(self) -> None: """ - Execute the command, including plugin registration, event dispatching, - and scenario execution. + Execute the command lifecycle. + + This method validates the configuration, registers plugins, parses arguments, + discovers scenarios, schedules them, and executes them. + + :raises Exception: If scenario discovery raises a `SystemExit`. """ self._validate_config() # Must be before ConfigLoadedEvent @@ -181,10 +222,23 @@ def _get_start_dir(self, args: Namespace) -> Path: """ Determine the starting directory for discovering scenarios. - :param args: Parsed command-line arguments - :return: The resolved starting directory + Resolves the starting directory based on the parsed arguments, ensuring + it is a valid directory inside the project directory. + + :param args: Parsed command-line arguments. + :return: The resolved starting directory. + :raises ValueError: If the starting directory is outside the project directory. """ - common_path = os.path.commonpath([self._normalize_path(x) for x in args.file_or_dir]) + file_or_dir = getattr(args, "file_or_dir", []) + # Note: `args.file_or_dir` is an argument that is registered by the core Skipper plugin. + # This introduces a dependency on the Skipper plugin's implementation, + # violating best practices, as the higher-level RunCommand component directly relies + # on a lower-level plugin. + # TODO: Fix this in v2.0 by introducing a more generic mechanism for passing arguments + if not file_or_dir: + return Path(self._config.default_scenarios_dir).resolve() + + common_path = os.path.commonpath([self._normalize_path(x) for x in file_or_dir]) start_dir = Path(common_path).resolve() if not start_dir.is_dir(): start_dir = start_dir.parent @@ -200,6 +254,14 @@ def _get_start_dir(self, args: Namespace) -> Path: return start_dir def _normalize_path(self, file_or_dir: str) -> str: + """ + Normalize the provided path and handle backward compatibility. + + Ensures the path is absolute and adjusts it based on legacy rules if necessary. + + :param file_or_dir: The path to normalize. + :return: The normalized absolute path. + """ path = os.path.normpath(file_or_dir) if os.path.isabs(path): return path diff --git a/vedro/plugins/skipper/_discoverer.py b/vedro/plugins/skipper/_discoverer.py index fbe11e7..62c10b4 100644 --- a/vedro/plugins/skipper/_discoverer.py +++ b/vedro/plugins/skipper/_discoverer.py @@ -8,14 +8,43 @@ class SelectiveScenarioDiscoverer(ScenarioDiscoverer): + """ + Discovers scenarios based on a set of selected paths. + + This class extends `ScenarioDiscoverer` and adds functionality to filter + scenarios by a predefined set of selected paths. Only scenarios within the + selected paths are discovered, loaded, and ordered. + """ + def __init__(self, finder: ScenarioFinder, loader: ScenarioLoader, orderer: ScenarioOrderer, *, selected_paths: Optional[Set[Path]] = None) -> None: + """ + Initialize the SelectiveScenarioDiscoverer with required components. + + :param finder: The `ScenarioFinder` instance used to find scenario files. + :param loader: The `ScenarioLoader` instance used to load scenarios. + :param orderer: The `ScenarioOrderer` instance used to arrange loaded scenarios. + :param selected_paths: A set of paths to filter scenarios. + If None, all scenarios are selected. + """ super().__init__(finder, loader, orderer) self._selected_paths = selected_paths async def discover(self, root: Path, *, project_dir: Optional[Path] = None) -> List[VirtualScenario]: + """ + Discover scenarios from a root path, filtered by selected paths. + + This method discovers scenarios starting from the specified root path. If + `selected_paths` is set, only scenarios within those paths are included. + Scenarios are located, loaded, and ordered before being returned. + + :param root: The root directory from where scenario discovery starts. + :param project_dir: An optional project directory used for resolving relative paths. + Defaults to the parent of the root directory if not provided. + :return: A list of discovered `VirtualScenario` instances. + """ if project_dir is None: # TODO: Make project_dir required in v2.0 project_dir = root.parent @@ -30,12 +59,30 @@ async def discover(self, root: Path, *, return await self._orderer.sort(scenarios) def _is_path_selected(self, path: Path) -> bool: + """ + Check if a given path is within the selected paths. + + This method determines whether a scenario path is included based on + the `selected_paths` filter. + + :param path: The path to check. + :return: True if the path is selected, False otherwise. + """ if self._selected_paths is None or len(self._selected_paths) == 0: return True abs_path = path.absolute() return any(self._is_relative_to(abs_path, selected) for selected in self._selected_paths) def _is_relative_to(self, path: Path, parent: Path) -> bool: + """ + Check if a path is relative to a parent directory. + + This method verifies whether a given path is a subpath of the specified parent directory. + + :param path: The path to check. + :param parent: The parent directory to check against. + :return: True if the path is relative to the parent directory, False otherwise. + """ try: path.relative_to(parent) except ValueError: