Skip to content

Commit

Permalink
add test for missing scenarios directory
Browse files Browse the repository at this point in the history
  • Loading branch information
tsv1 committed Jan 5, 2025
1 parent 1d4992b commit c87a72c
Show file tree
Hide file tree
Showing 4 changed files with 168 additions and 18 deletions.
16 changes: 16 additions & 0 deletions tests/commands/run_command/test_run_command.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pathlib import Path

import pytest
from baby_steps import given, then, when
from pytest import raises

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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):
Expand Down
25 changes: 25 additions & 0 deletions vedro/commands/_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
98 changes: 80 additions & 18 deletions vedro/commands/run_command/_run_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand All @@ -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__
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions vedro/plugins/skipper/_discoverer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down

0 comments on commit c87a72c

Please sign in to comment.