diff --git a/tests/core/exp/local_storage/_utils.py b/tests/core/exp/local_storage/_utils.py index e11b4fdc..a31b714b 100644 --- a/tests/core/exp/local_storage/_utils.py +++ b/tests/core/exp/local_storage/_utils.py @@ -21,4 +21,4 @@ class CustomPluginConfig(PluginConfig): @pytest.fixture() def local_storage(plugin: Plugin, tmp_path: Path): - return LocalStorage(plugin, tmp_path) + return LocalStorage(plugin, project_dir=tmp_path) diff --git a/tests/core/exp/local_storage/test_local_storage.py b/tests/core/exp/local_storage/test_local_storage.py index fa6c4f1c..f5639d20 100644 --- a/tests/core/exp/local_storage/test_local_storage.py +++ b/tests/core/exp/local_storage/test_local_storage.py @@ -50,10 +50,10 @@ async def test_get_without_flush(plugin: Plugin, tmp_path: Path): with given: key, value = "", "" - local_storage1 = LocalStorage(plugin, tmp_path) + local_storage1 = LocalStorage(plugin, project_dir=tmp_path) await local_storage1.put(key, value) - local_storage2 = LocalStorage(plugin, tmp_path) + local_storage2 = LocalStorage(plugin, project_dir=tmp_path) with when: res = await local_storage2.get(key) @@ -66,11 +66,11 @@ async def test_get_with_flush(plugin: Plugin, tmp_path: Path): with given: key, value = "", "" - local_storage1 = LocalStorage(plugin, tmp_path) + local_storage1 = LocalStorage(plugin, project_dir=tmp_path) await local_storage1.put(key, value) await local_storage1.flush() - local_storage2 = LocalStorage(plugin, tmp_path) + local_storage2 = LocalStorage(plugin, project_dir=tmp_path) with when: res = await local_storage2.get(key) diff --git a/tests/core/test_artifacts.py b/tests/core/test_artifacts.py index bc61dec3..abed2342 100644 --- a/tests/core/test_artifacts.py +++ b/tests/core/test_artifacts.py @@ -40,7 +40,8 @@ def test_memory_artifact_binary_only(): MemoryArtifact("log", "text/plain", "text") with then: - assert exc_info.type is AssertionError + assert exc_info.type is TypeError + assert str(exc_info.value) == "'data' must be of type bytes" def test_memory_artifact_eq(): @@ -108,7 +109,8 @@ def test_file_artifact_path_only(): FileArtifact("log", "text/plain", "./log.txt") with then: - assert exc_info.type is AssertionError + assert exc_info.type is TypeError + assert str(exc_info.value) == "'path' must be an instance of pathlib.Path" def test_file_artifact_eq(): diff --git a/tests/plugins/artifacted/_utils.py b/tests/plugins/artifacted/_utils.py index 6444e4df..02d38998 100644 --- a/tests/plugins/artifacted/_utils.py +++ b/tests/plugins/artifacted/_utils.py @@ -1,11 +1,16 @@ +from argparse import ArgumentParser, Namespace from collections import deque from pathlib import Path from time import monotonic_ns +from typing import Optional import pytest -from vedro import Scenario +from vedro import Config as _Config +from vedro import FileArtifact +from vedro import Scenario as VedroScenario from vedro.core import Dispatcher, VirtualScenario, VirtualStep +from vedro.events import ArgParsedEvent, ArgParseEvent, ConfigLoadedEvent from vedro.plugins.artifacted import Artifacted, ArtifactedPlugin, MemoryArtifact @@ -24,6 +29,11 @@ def step_artifacts() -> deque: return deque() +@pytest.fixture() +def project_dir(tmp_path: Path) -> Path: + return tmp_path + + @pytest.fixture() def artifacted(dispatcher: Dispatcher, scenario_artifacts: deque, step_artifacts: deque) -> ArtifactedPlugin: @@ -34,16 +44,37 @@ def artifacted(dispatcher: Dispatcher, scenario_artifacts: deque, return artifacted -def make_vscenario() -> VirtualScenario: - class _Scenario(Scenario): - __file__ = Path(f"scenario_{monotonic_ns()}.py").absolute() +def make_vscenario(file_path: str = "scenarios/scenario.py") -> VirtualScenario: + class Scenario(VedroScenario): + __file__ = Path(file_path).absolute() - return VirtualScenario(_Scenario, steps=[]) + return VirtualScenario(Scenario, steps=[]) def make_vstep() -> VirtualStep: return VirtualStep(lambda: None) -def create_artifact() -> MemoryArtifact: - return MemoryArtifact(f"test-{monotonic_ns()}", "text/plain", b"") +def create_memory_artifact(content: str = "text") -> MemoryArtifact: + return MemoryArtifact(f"test-{monotonic_ns()}.txt", "text/plain", content.encode()) + + +def create_file_artifact(path: Path, content: str = "text") -> FileArtifact: + path.write_text(content) + return FileArtifact(f"test-{monotonic_ns()}.txt", "text/plain", path) + + +async def fire_config_loaded_event(dispatcher: Dispatcher, project_dir_: Path) -> None: + class Config(_Config): + project_dir = project_dir_ + + await dispatcher.fire(ConfigLoadedEvent(Path(), Config)) + + +async def fire_arg_parsed_event(dispatcher: Dispatcher, *, + save_artifacts: bool = False, + artifacts_dir: Optional[Path] = None) -> None: + await dispatcher.fire(ArgParseEvent(ArgumentParser())) + + namespace = Namespace(save_artifacts=save_artifacts, artifacts_dir=artifacts_dir) + await dispatcher.fire(ArgParsedEvent(namespace)) diff --git a/tests/plugins/artifacted/test_artifacted_plugin.py b/tests/plugins/artifacted/test_artifacted_plugin.py index fa1a1bcb..e9f75c3c 100644 --- a/tests/plugins/artifacted/test_artifacted_plugin.py +++ b/tests/plugins/artifacted/test_artifacted_plugin.py @@ -1,17 +1,21 @@ from collections import deque +from pathlib import Path import pytest from baby_steps import given, then, when +from pytest import raises -from vedro.core import Dispatcher, ScenarioResult, StepResult +from vedro.core import AggregatedResult, Dispatcher, ScenarioResult, StepResult from vedro.events import ( ScenarioFailedEvent, ScenarioPassedEvent, + ScenarioReportedEvent, ScenarioRunEvent, StepFailedEvent, StepPassedEvent, ) from vedro.plugins.artifacted import ( + Artifact, Artifacted, ArtifactedPlugin, attach_artifact, @@ -21,26 +25,83 @@ from ._utils import ( artifacted, - create_artifact, + create_file_artifact, + create_memory_artifact, dispatcher, + fire_arg_parsed_event, + fire_config_loaded_event, make_vscenario, make_vstep, + project_dir, scenario_artifacts, step_artifacts, ) -__all__ = ("dispatcher", "scenario_artifacts", "step_artifacts", "artifacted") # fixtures +__all__ = ("dispatcher", "scenario_artifacts", "step_artifacts", "artifacted", + "project_dir") # fixtures @pytest.mark.usefixtures(artifacted.__name__) -async def test_scenario_run_event(*, dispatcher: Dispatcher, scenario_artifacts: deque, - step_artifacts: deque): +async def test_arg_parsed_event_with_artifacts_dir_created(*, dispatcher: Dispatcher, + project_dir: Path): + with given: + await fire_config_loaded_event(dispatcher, project_dir) + + artifacts_dir = project_dir / "artifacts/" + artifacts_dir.mkdir(exist_ok=True) + + with when: + await fire_arg_parsed_event(dispatcher, save_artifacts=True, artifacts_dir=artifacts_dir) + + with then: + assert artifacts_dir.exists() is False + + +@pytest.mark.usefixtures(artifacted.__name__) +async def test_arg_parsed_event_error_on_disabled_artifact_saving(*, dispatcher: Dispatcher, + project_dir: Path): + with given: + await fire_config_loaded_event(dispatcher, project_dir) + + artifacts_dir = Path("./artifacts") + + with when, raises(BaseException) as exc: + await fire_arg_parsed_event(dispatcher, save_artifacts=False, artifacts_dir=artifacts_dir) + + with then: + assert exc.type is ValueError + assert str(exc.value) == ( + "Artifacts directory cannot be specified when artifact saving is disabled" + ) + + +@pytest.mark.usefixtures(artifacted.__name__) +async def test_arg_parsed_event_error_outside_artifacts_dir(*, dispatcher: Dispatcher, + project_dir: Path): + with given: + await fire_config_loaded_event(dispatcher, project_dir) + + artifacts_dir = Path("../artifacts") + + with when, raises(BaseException) as exc: + await fire_arg_parsed_event(dispatcher, save_artifacts=True, artifacts_dir=artifacts_dir) + + with then: + assert exc.type is ValueError + artifacts_dir = (project_dir / artifacts_dir).resolve() + assert str(exc.value) == (f"Artifacts directory '{artifacts_dir}' must be " + f"within the project directory '{project_dir}'") + + +@pytest.mark.usefixtures(artifacted.__name__) +async def test_run_event_clears_artifacts(*, dispatcher: Dispatcher, scenario_artifacts: deque, + step_artifacts: deque): with given: scenario_result = ScenarioResult(make_vscenario()) event = ScenarioRunEvent(scenario_result) - scenario_artifacts.append(create_artifact()) - step_artifacts.append(create_artifact()) + scenario_artifacts.append(create_memory_artifact()) + step_artifacts.append(create_memory_artifact()) with when: await dispatcher.fire(event) @@ -52,18 +113,19 @@ async def test_scenario_run_event(*, dispatcher: Dispatcher, scenario_artifacts: @pytest.mark.usefixtures(artifacted.__name__) @pytest.mark.parametrize("event_class", [ScenarioPassedEvent, ScenarioFailedEvent]) -async def test_scenario_end_event(event_class, *, dispatcher: Dispatcher, - scenario_artifacts: deque): +async def test_scenario_end_event_attaches_artifacts(event_class, *, dispatcher: Dispatcher, + scenario_artifacts: deque): with given: scenario_result = ScenarioResult(make_vscenario()) - event = event_class(scenario_result) - artifact1 = create_artifact() + artifact1 = create_memory_artifact() scenario_artifacts.append(artifact1) - artifact2 = create_artifact() + artifact2 = create_memory_artifact() scenario_artifacts.append(artifact2) + event = event_class(scenario_result) + with when: await dispatcher.fire(event) @@ -73,17 +135,19 @@ async def test_scenario_end_event(event_class, *, dispatcher: Dispatcher, @pytest.mark.usefixtures(artifacted.__name__) @pytest.mark.parametrize("event_class", [StepPassedEvent, StepFailedEvent]) -async def test_step_end_event(event_class, *, dispatcher: Dispatcher, step_artifacts: deque): +async def test_step_end_event_attaches_artifacts(event_class, *, dispatcher: Dispatcher, + step_artifacts: deque): with given: step_result = StepResult(make_vstep()) - event = event_class(step_result) - artifact1 = create_artifact() + artifact1 = create_memory_artifact() step_artifacts.append(artifact1) - artifact2 = create_artifact() + artifact2 = create_memory_artifact() step_artifacts.append(artifact2) + event = event_class(step_result) + with when: await dispatcher.fire(event) @@ -91,13 +155,104 @@ async def test_step_end_event(event_class, *, dispatcher: Dispatcher, step_artif assert step_result.artifacts == [artifact1, artifact2] +@pytest.mark.usefixtures(artifacted.__name__) +async def test_scenario_reported_event_saves_scenario_artifacts(*, dispatcher: Dispatcher, + project_dir: Path): + with given: + await fire_config_loaded_event(dispatcher, project_dir) + await fire_arg_parsed_event(dispatcher, save_artifacts=True) + + scenario_result = ScenarioResult(make_vscenario()) + scenario_result.set_started_at(3.14) + + file_path = project_dir / "test.txt" + file_content = "text" + artifact1 = create_memory_artifact(f"{file_content}-1") + artifact2 = create_file_artifact(file_path, f"{file_content}-2") + scenario_result.attach(artifact1) + scenario_result.attach(artifact2) + + aggregated_result = AggregatedResult.from_existing(scenario_result, [scenario_result]) + event = ScenarioReportedEvent(aggregated_result) + + with when: + await dispatcher.fire(event) + + with then: + scn_artifacts_path = project_dir / ".vedro/artifacts/scenarios/scenario/3-14-Scenario-0" + assert scn_artifacts_path.exists() + + artifact1_path = scn_artifacts_path / artifact1.name + assert artifact1_path.exists() + assert artifact1_path.read_text() == "text-1" + + artifact2_path = scn_artifacts_path / artifact2.name + assert artifact2_path.exists() + assert artifact2_path.read_text() == "text-2" + + +@pytest.mark.usefixtures(artifacted.__name__) +async def test_scenario_reported_event_saves_step_artifacts(*, dispatcher: Dispatcher, + project_dir: Path): + with given: + await fire_config_loaded_event(dispatcher, project_dir) + await fire_arg_parsed_event(dispatcher, save_artifacts=True) + + step_result = StepResult(make_vstep()) + artifact = create_memory_artifact(content := "text") + step_result.attach(artifact) + + scenario_result = ScenarioResult(make_vscenario()) + scenario_result.set_started_at(3.14) + scenario_result.add_step_result(step_result) + + aggregated_result = AggregatedResult.from_existing(scenario_result, [scenario_result]) + event = ScenarioReportedEvent(aggregated_result) + + with when: + await dispatcher.fire(event) + + with then: + scn_artifacts_path = project_dir / ".vedro/artifacts/scenarios/scenario/3-14-Scenario-0" + assert scn_artifacts_path.exists() + + step_artifacts_path = scn_artifacts_path / artifact.name + assert step_artifacts_path.exists() + assert step_artifacts_path.read_text() == content + + +@pytest.mark.usefixtures(artifacted.__name__) +async def test_scenario_reported_event_incorrect_artifact(*, dispatcher: Dispatcher, + project_dir: Path): + with given: + await fire_config_loaded_event(dispatcher, project_dir) + await fire_arg_parsed_event(dispatcher, save_artifacts=True) + + scenario_result = ScenarioResult(make_vscenario()) + artifact = type("NewArtifact", (Artifact,), {})() + scenario_result.attach(artifact) + + aggregated_result = AggregatedResult.from_existing(scenario_result, [scenario_result]) + event = ScenarioReportedEvent(aggregated_result) + + with when, raises(BaseException) as exc: + await dispatcher.fire(event) + + with then: + assert exc.type is TypeError + assert str(exc.value) == ( + "Can't save artifact to '.vedro/artifacts/scenarios/scenario/0-Scenario-0': " + "unknown type 'NewArtifact'" + ) + + @pytest.mark.parametrize("event_class", [ScenarioPassedEvent, ScenarioFailedEvent]) async def test_attach_scenario_artifact(event_class, *, dispatcher: Dispatcher): with given: artifacted = ArtifactedPlugin(Artifacted) artifacted.subscribe(dispatcher) - artifact = create_artifact() + artifact = create_memory_artifact() attach_scenario_artifact(artifact) scenario_result = ScenarioResult(make_vscenario()) @@ -117,7 +272,7 @@ async def test_attach_step_artifact(attach, event_class, *, dispatcher: Dispatch artifacted = ArtifactedPlugin(Artifacted) artifacted.subscribe(dispatcher) - artifact = create_artifact() + artifact = create_memory_artifact() attach(artifact) step_result = StepResult(make_vstep()) diff --git a/tests/plugins/last_failed/_utils.py b/tests/plugins/last_failed/_utils.py index 3cb1f72e..07b1cf0a 100644 --- a/tests/plugins/last_failed/_utils.py +++ b/tests/plugins/last_failed/_utils.py @@ -23,7 +23,7 @@ def make_last_failed(dispatcher: Dispatcher, def create_local_storage(plugin: Plugin): nonlocal local_storage - local_storage = LocalStorage(plugin, tmp_path) + local_storage = LocalStorage(plugin, project_dir=tmp_path) return local_storage plugin = LastFailedPlugin(LastFailed, local_storage_factory=create_local_storage) diff --git a/tests/plugins/system_upgrade/_utils.py b/tests/plugins/system_upgrade/_utils.py index 233330c1..4e81be08 100644 --- a/tests/plugins/system_upgrade/_utils.py +++ b/tests/plugins/system_upgrade/_utils.py @@ -26,7 +26,7 @@ def make_system_upgrade(dispatcher: Dispatcher, def create_local_storage(plugin: Plugin): nonlocal local_storage - local_storage = LocalStorage(plugin, tmp_path) + local_storage = LocalStorage(plugin, project_dir=tmp_path) return local_storage plugin = SystemUpgradePlugin(SystemUpgrade, local_storage_factory=create_local_storage) diff --git a/vedro/__init__.py b/vedro/__init__.py index 8896ba47..f4e661e1 100644 --- a/vedro/__init__.py +++ b/vedro/__init__.py @@ -3,6 +3,15 @@ import sys from typing import Any +from vedro.plugins.artifacted import ( + Artifact, + FileArtifact, + MemoryArtifact, + attach_artifact, + attach_scenario_artifact, + attach_step_artifact, +) + from ._catched import catched from ._config import Config from ._context import context @@ -17,7 +26,9 @@ __version__ = version __all__ = ("Scenario", "Interface", "run", "only", "skip", "skip_if", "params", - "context", "defer", "Config", "catched", "create_tmp_dir", "create_tmp_file",) + "context", "defer", "Config", "catched", "create_tmp_dir", "create_tmp_file", + "attach_artifact", "attach_scenario_artifact", "attach_step_artifact", + "MemoryArtifact", "FileArtifact", "Artifact",) def run(*, plugins: Any = None) -> None: diff --git a/vedro/_config.py b/vedro/_config.py index 83600bd8..abe54ce5 100644 --- a/vedro/_config.py +++ b/vedro/_config.py @@ -100,10 +100,10 @@ class LastFailed(last_failed.LastFailed): class Deferrer(deferrer.Deferrer): enabled = True - class Artifacted(artifacted.Artifacted): + class Seeder(seeder.Seeder): enabled = True - class Seeder(seeder.Seeder): + class Artifacted(artifacted.Artifacted): enabled = True class Skipper(skipper.Skipper): diff --git a/vedro/core/_artifacts.py b/vedro/core/_artifacts.py index 7e78cd61..443a8b8e 100644 --- a/vedro/core/_artifacts.py +++ b/vedro/core/_artifacts.py @@ -6,57 +6,141 @@ class Artifact(ABC): + """ + The base class for representing artifacts in a system. + + An artifact in this context is a piece of data generated during the execution + of a scenario or a step. It can be anything from log files, screenshots, to data dumps. + This class serves as an abstract base class for different types of artifacts, + such as MemoryArtifact and FileArtifact. + """ pass class MemoryArtifact(Artifact): + """ + Represents an artifact that is stored in memory. + """ + def __init__(self, name: str, mime_type: str, data: bytes) -> None: - assert isinstance(data, bytes) + """ + Initialize a MemoryArtifact with a name, MIME type, and data. + + :param name: The name of the artifact. + :param mime_type: The MIME type of the data. + :param data: The actual data in bytes. + """ + if not isinstance(data, bytes): + raise TypeError("'data' must be of type bytes") self._name = name self._data = data self._mime_type = mime_type @property def name(self) -> str: + """ + Get the name of the artifact. + + :return: The name of the artifact. + """ return self._name @property def mime_type(self) -> str: + """ + Get the MIME type of the artifact data. + + :return: The MIME type as a string. + """ return self._mime_type @property def data(self) -> bytes: + """ + Get the data stored in the artifact. + + :return: The data as bytes. + """ return self._data def __repr__(self) -> str: + """ + Represent the MemoryArtifact as a string. + + :return: A string representation of the MemoryArtifact. + """ size = len(self._data) return f"{self.__class__.__name__}<{self._name!r}, {self._mime_type!r}, size={size}>" def __eq__(self, other: Any) -> bool: + """ + Check equality with another MemoryArtifact. + + :param other: The other MemoryArtifact to compare with. + :return: True if the other artifact is equal to this one, False otherwise. + """ return isinstance(other, self.__class__) and (self.__dict__ == other.__dict__) class FileArtifact(Artifact): + """ + Represents an artifact that is stored as a file on the filesystem. + """ + def __init__(self, name: str, mime_type: str, path: Path) -> None: - assert isinstance(path, Path) + """ + Initialize a FileArtifact with a name, MIME type, and file path. + + :param name: The name of the artifact. + :param mime_type: The MIME type of the file. + :param path: The path to the file. + """ + if not isinstance(path, Path): + raise TypeError("'path' must be an instance of pathlib.Path") self._name = name self._path = path self._mime_type = mime_type @property def name(self) -> str: + """ + Get the name of the artifact. + + :return: The name of the artifact. + """ return self._name @property def mime_type(self) -> str: + """ + Get the MIME type of the artifact data. + + :return: The MIME type as a string. + """ return self._mime_type @property def path(self) -> Path: + """ + Get the file path of the artifact. + + :return: The path of the file as a Path object. + """ return self._path def __repr__(self) -> str: + """ + Represent the FileArtifact as a string. + + :return: A string representation of the FileArtifact. + """ return f"{self.__class__.__name__}<{self._name!r}, {self._mime_type!r}, {self._path!r}>" def __eq__(self, other: Any) -> bool: + """ + Check equality with another FileArtifact. + + :param other: The other FileArtifact to compare with. + :return: True if the other artifact is equal to this one, False otherwise. + """ return isinstance(other, self.__class__) and (self.__dict__ == other.__dict__) diff --git a/vedro/core/exp/local_storage/__init__.py b/vedro/core/exp/local_storage/__init__.py index 6f14a400..56825809 100644 --- a/vedro/core/exp/local_storage/__init__.py +++ b/vedro/core/exp/local_storage/__init__.py @@ -1,4 +1,3 @@ -from pathlib import Path from typing import Callable from ..._plugin import Plugin @@ -9,15 +8,15 @@ def create_local_storage(plugin: Plugin) -> LocalStorage: """ - Create a new LocalStorage instance for the given plugin. + Create and return a new LocalStorage instance for a given plugin. - :param plugin: The Plugin instance for which to create a LocalStorage. - :return: The newly created LocalStorage instance. - :raises TypeError: If the input is not a Plugin instance. + :param plugin: The Plugin instance for which the LocalStorage is to be created. + :return: A LocalStorage instance associated with the given plugin. + :raises TypeError: If the provided plugin is not an instance of Plugin. """ if not isinstance(plugin, Plugin): raise TypeError(f"Expected Plugin instance, but got {type(plugin)}") - return LocalStorage(plugin, Path(".vedro/local_storage")) + return LocalStorage(plugin) __all__ = ("create_local_storage", "LocalStorageFactory", "LocalStorage",) diff --git a/vedro/core/exp/local_storage/_local_storage.py b/vedro/core/exp/local_storage/_local_storage.py index 1dc4ce0d..da9e03a7 100644 --- a/vedro/core/exp/local_storage/_local_storage.py +++ b/vedro/core/exp/local_storage/_local_storage.py @@ -26,19 +26,20 @@ class LocalStorage: the 'flush' method explicitly. """ - def __init__(self, plugin: Plugin, directory: Path, *, + def __init__(self, plugin: Plugin, *, + project_dir: Path = Path.cwd(), lock_factory: LockFactory = _lock_factory) -> None: """ Initialize a new instance of LocalStorage. :param plugin: Plugin instance that provides namespace for the storage file. - :param directory: Path to the directory where the JSON file is located. + :param project_dir: The root directory of the project. :param lock_factory: Factory function to create a file lock. """ namespace = f"{plugin.__class__.__name__}" - self._dir_path = directory - self._file_path = directory / f"{namespace}.json" - self._lock_path = directory / f"{namespace}.lock" + self._dir_path = (project_dir / ".vedro" / "local_storage/").resolve() + self._file_path = self._dir_path / f"{namespace}.json" + self._lock_path = self._dir_path / f"{namespace}.lock" self._lock_factory = lock_factory self._lock: Union[FileLock, None] = None self._storage: Union[Dict[str, Any], None] = None diff --git a/vedro/plugins/artifacted/_artifacted.py b/vedro/plugins/artifacted/_artifacted.py index e4ab62bf..6fa94d0b 100644 --- a/vedro/plugins/artifacted/_artifacted.py +++ b/vedro/plugins/artifacted/_artifacted.py @@ -1,10 +1,26 @@ +import shutil from collections import deque +from pathlib import Path from typing import Deque, Type, Union -from vedro.core import Artifact, Dispatcher, Plugin, PluginConfig +from vedro.core import ( + Artifact, + ConfigType, + Dispatcher, + FileArtifact, + MemoryArtifact, + Plugin, + PluginConfig, + ScenarioResult, + StepResult, +) from vedro.events import ( + ArgParsedEvent, + ArgParseEvent, + ConfigLoadedEvent, ScenarioFailedEvent, ScenarioPassedEvent, + ScenarioReportedEvent, ScenarioRunEvent, StepFailedEvent, StepPassedEvent, @@ -37,13 +53,53 @@ def __init__(self, config: Type["Artifacted"], *, super().__init__(config) self._scenario_artifacts = scenario_artifacts self._step_artifacts = step_artifacts + self._save_artifacts = config.save_artifacts + self._artifacts_dir = config.artifacts_dir + self._add_artifact_details = config.add_artifact_details + self._global_config: Union[ConfigType, None] = None def subscribe(self, dispatcher: Dispatcher) -> None: - dispatcher.listen(ScenarioRunEvent, self.on_scenario_run) \ + dispatcher.listen(ConfigLoadedEvent, self.on_config_loaded) \ + .listen(ArgParseEvent, self.on_arg_parse) \ + .listen(ArgParsedEvent, self.on_arg_parsed) \ + .listen(ScenarioRunEvent, self.on_scenario_run) \ .listen(StepPassedEvent, self.on_step_end) \ .listen(StepFailedEvent, self.on_step_end) \ .listen(ScenarioPassedEvent, self.on_scenario_end) \ - .listen(ScenarioFailedEvent, self.on_scenario_end) + .listen(ScenarioFailedEvent, self.on_scenario_end) \ + .listen(ScenarioReportedEvent, self.on_scenario_reported) + + def on_config_loaded(self, event: ConfigLoadedEvent) -> None: + self._global_config = event.config + + def on_arg_parse(self, event: ArgParseEvent) -> None: + group = event.arg_parser.add_argument_group("Artifacted") + + group.add_argument("--save-artifacts", action="store_true", + default=self._save_artifacts, + help="Save artifacts to the file system") + group.add_argument("--artifacts-dir", type=Path, default=None, + help=("Specify the directory path for saving artifacts " + f"(default: '{self._artifacts_dir}')")) + + def on_arg_parsed(self, event: ArgParsedEvent) -> None: + self._save_artifacts = event.args.save_artifacts + if not self._save_artifacts: + if event.args.artifacts_dir is not None: + raise ValueError( + "Artifacts directory cannot be specified when artifact saving is disabled") + return + self._artifacts_dir = event.args.artifacts_dir or self._artifacts_dir + + project_dir = self._get_project_dir() + if not self._artifacts_dir.is_absolute(): + self._artifacts_dir = (project_dir / self._artifacts_dir).resolve() + if not self._is_relative_to(self._artifacts_dir, project_dir): + raise ValueError(f"Artifacts directory '{self._artifacts_dir}' " + f"must be within the project directory '{project_dir}'") + + if self._artifacts_dir.exists(): + shutil.rmtree(self._artifacts_dir) def on_scenario_run(self, event: ScenarioRunEvent) -> None: self._scenario_artifacts.clear() @@ -60,7 +116,85 @@ async def on_scenario_end(self, artifact = self._scenario_artifacts.popleft() event.scenario_result.attach(artifact) + async def on_scenario_reported(self, event: ScenarioReportedEvent) -> None: + if not self._save_artifacts: + return + + aggregated_result = event.aggregated_result + for scenario_result in aggregated_result.scenario_results: + scenario_artifacts_dir = self._get_scenario_artifacts_dir(scenario_result) + + for step_result in scenario_result.step_results: + for artifact in step_result.artifacts: + artifact_path = self._save_artifact(artifact, scenario_artifacts_dir) + self._add_extra_details(step_result, artifact_path) + + for artifact in scenario_result.artifacts: + artifact_path = self._save_artifact(artifact, scenario_artifacts_dir) + self._add_extra_details(scenario_result, artifact_path) + + def _is_relative_to(self, path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + except ValueError: + return False + else: + return path != parent + + def _add_extra_details(self, result: Union[ScenarioResult, StepResult], + artifact_path: Path) -> None: + if self._add_artifact_details: + rel_path = artifact_path.relative_to(self._get_project_dir()) + result.add_extra_details(f"artifact '{rel_path}'") + + def _get_project_dir(self) -> Path: + assert self._global_config is not None + return self._global_config.project_dir.resolve() + + def _get_scenario_artifacts_dir(self, scenario_result: ScenarioResult) -> Path: + scenario = scenario_result.scenario + scenario_path = self._artifacts_dir / scenario.rel_path.with_suffix('') + + template_index = str(scenario.template_index or 0) + started_at = str(scenario_result.started_at or 0).replace(".", "-") + scenario_path /= f"{started_at}-{scenario.name}-{template_index}" + + return scenario_path + + def _save_artifact(self, artifact: Artifact, scenario_path: Path) -> Path: + if not scenario_path.exists(): + scenario_path.mkdir(parents=True, exist_ok=True) + + if isinstance(artifact, MemoryArtifact): + artifact_dest_path = (scenario_path / artifact.name).resolve() + artifact_dest_path.write_bytes(artifact.data) + return artifact_dest_path + + elif isinstance(artifact, FileArtifact): + artifact_dest_path = (scenario_path / artifact.name).resolve() + artifact_source_path = artifact.path + if not artifact_source_path.is_absolute(): + artifact_source_path = (self._get_project_dir() / artifact_source_path).resolve() + shutil.copy2(artifact_source_path, artifact_dest_path) + return artifact_dest_path + + else: + artifact_type = type(artifact).__name__ + rel_path = scenario_path.relative_to(self._get_project_dir()) + raise TypeError(f"Can't save artifact to '{rel_path}': unknown type '{artifact_type}'") + class Artifacted(PluginConfig): plugin = ArtifactedPlugin description = "Manages artifacts for step and scenario results" + + # Save artifacts to the file system + save_artifacts: bool = False + + # Directory path for saving artifacts + # Available if `save_artifacts` is True + artifacts_dir: Path = Path(".vedro/artifacts/") + + # Add artifact details to scenario and steps extras + # Available if `save_artifacts` is True + add_artifact_details: bool = True diff --git a/vedro/plugins/director/rich/_rich_reporter.py b/vedro/plugins/director/rich/_rich_reporter.py index d21cfc0b..d9797859 100644 --- a/vedro/plugins/director/rich/_rich_reporter.py +++ b/vedro/plugins/director/rich/_rich_reporter.py @@ -269,7 +269,7 @@ def on_scenario_reported(self, event: ScenarioReportedEvent) -> None: aggregated_result = event.aggregated_result rescheduled = len(aggregated_result.scenario_results) if rescheduled == 1: - self._print_scenario_result(aggregated_result, prefix=" ") + self._print_scenario_result(aggregated_result.scenario_results[0], prefix=" ") return self._printer.print_scenario_subject(aggregated_result.scenario.subject, diff --git a/vedro/plugins/temp_keeper/_temp_file_manager.py b/vedro/plugins/temp_keeper/_temp_file_manager.py index 5dec3a2f..5336898a 100644 --- a/vedro/plugins/temp_keeper/_temp_file_manager.py +++ b/vedro/plugins/temp_keeper/_temp_file_manager.py @@ -22,7 +22,7 @@ def __init__(self, project_dir: Path = Path.cwd()) -> None: :param project_dir: The root directory of the project. Defaults to the current working directory. """ - self._project_dir = project_dir + self._project_dir = project_dir.resolve() def get_project_dir(self) -> Path: """