From 87297ba8f0c25ac9741253e4ae2f8d395d9fc8e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikit=D0=B0=20Tsvetk=D0=BEv?= Date: Sat, 30 Nov 2024 18:24:40 +0400 Subject: [PATCH] Add support for global artifacts in ArtifactedPlugin (#107) --- setup.cfg | 1 + .../scenario_result/test_scenario_result.py | 2 +- tests/core/test_report.py | 44 ++++ tests/core/test_step_result.py | 2 +- tests/plugins/artifacted/_utils.py | 55 ++++- .../artifacted/test_artifact_manager.py | 187 +++++++++++++++++ .../artifacted/test_artifacted_plugin.py | 189 ++++++++--------- .../artifacted/test_attach_artifact.py | 95 +++++++++ vedro/__init__.py | 4 +- vedro/core/_report.py | 115 +++++++++- vedro/core/_step_result.py | 130 +++++++++++- .../core/scenario_result/_scenario_result.py | 150 ++++++++++++- vedro/plugins/artifacted/__init__.py | 8 +- vedro/plugins/artifacted/_artifact_manager.py | 175 ++++++++++++++++ vedro/plugins/artifacted/_artifacted.py | 197 ++++++++++++------ vedro/plugins/artifacted/_utils.py | 19 ++ 16 files changed, 1186 insertions(+), 187 deletions(-) create mode 100644 tests/plugins/artifacted/test_artifact_manager.py create mode 100644 tests/plugins/artifacted/test_attach_artifact.py create mode 100644 vedro/plugins/artifacted/_artifact_manager.py create mode 100644 vedro/plugins/artifacted/_utils.py diff --git a/setup.cfg b/setup.cfg index 531dfb61..9ed8d0f8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -45,5 +45,6 @@ markers = only asyncio_mode = auto filterwarnings = ignore:Deprecated + ignore::DeprecationWarning:dessert addopts = --basetemp=./__tmpdir__ diff --git a/tests/core/scenario_result/test_scenario_result.py b/tests/core/scenario_result/test_scenario_result.py index 394806b6..9c58f149 100644 --- a/tests/core/scenario_result/test_scenario_result.py +++ b/tests/core/scenario_result/test_scenario_result.py @@ -314,7 +314,7 @@ def test_scenario_result_attach_incorrect_artifact(*, virtual_scenario: VirtualS with then: assert exc.type is TypeError - assert str(exc.value) == "artifact must be an instance of Artifact" + assert str(exc.value) == "Expected an instance of Artifact, got dict" def test_scenario_result_get_artifacts(*, virtual_scenario: VirtualScenario): diff --git a/tests/core/test_report.py b/tests/core/test_report.py index ac3b49a9..94ca006f 100644 --- a/tests/core/test_report.py +++ b/tests/core/test_report.py @@ -2,7 +2,9 @@ from unittest.mock import Mock from baby_steps import given, then, when +from pytest import raises +from vedro import MemoryArtifact from vedro.core import ExcInfo, Report, ScenarioResult, VirtualScenario @@ -255,3 +257,45 @@ def test_repr_with_interrupted(): with then: assert res == (">") + + +def test_report_attach_artifact(): + with given: + artifact = MemoryArtifact("name", "text/plain", b"") + report = Report() + + with when: + res = report.attach(artifact) + + with then: + assert res is None + + +def test_report_attach_incorrect_artifact(): + with given: + artifact = {} + report = Report() + + with when, raises(BaseException) as exc: + report.attach(artifact) + + with then: + assert exc.type is TypeError + assert str(exc.value) == "Expected an instance of Artifact, got dict" + + +def test_report_get_artifacts(): + with given: + report = Report() + + artifact1 = MemoryArtifact("name1", "text/plain", b"") + report.attach(artifact1) + + artifact2 = MemoryArtifact("name2", "text/plain", b"") + report.attach(artifact2) + + with when: + artifacts = report.artifacts + + with then: + assert artifacts == [artifact1, artifact2] diff --git a/tests/core/test_step_result.py b/tests/core/test_step_result.py index 93c152ef..0c916cf3 100644 --- a/tests/core/test_step_result.py +++ b/tests/core/test_step_result.py @@ -207,7 +207,7 @@ def test_step_result_attach_incorrect_artifact(*, virtual_step: VirtualStep): with then: assert exc.type is TypeError - assert str(exc.value) == "artifact must be an instance of Artifact" + assert str(exc.value) == "Expected an instance of Artifact, got dict" def test_step_result_get_artifacts(*, virtual_step: VirtualStep): diff --git a/tests/plugins/artifacted/_utils.py b/tests/plugins/artifacted/_utils.py index 02d38998..e735a2d1 100644 --- a/tests/plugins/artifacted/_utils.py +++ b/tests/plugins/artifacted/_utils.py @@ -3,6 +3,7 @@ from pathlib import Path from time import monotonic_ns from typing import Optional +from unittest.mock import patch import pytest @@ -11,7 +12,7 @@ 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 +from vedro.plugins.artifacted import Artifacted, ArtifactedPlugin, ArtifactManager, MemoryArtifact @pytest.fixture() @@ -19,6 +20,11 @@ def dispatcher() -> Dispatcher: return Dispatcher() +@pytest.fixture() +def global_artifacts() -> deque: + return deque() + + @pytest.fixture() def scenario_artifacts() -> deque: return deque() @@ -35,9 +41,24 @@ def project_dir(tmp_path: Path) -> Path: @pytest.fixture() -def artifacted(dispatcher: Dispatcher, scenario_artifacts: deque, +def artifacts_dir(project_dir: Path): + artifacts_dir = project_dir / "artifacts/" + artifacts_dir.mkdir(exist_ok=True) + return artifacts_dir + + +@pytest.fixture() +def artifact_manager(artifacts_dir: Path, project_dir: Path): + return ArtifactManager(artifacts_dir, project_dir) + + +@pytest.fixture() +def artifacted(dispatcher: Dispatcher, + global_artifacts: deque, + scenario_artifacts: deque, step_artifacts: deque) -> ArtifactedPlugin: artifacted = ArtifactedPlugin(Artifacted, + global_artifacts=global_artifacts, scenario_artifacts=scenario_artifacts, step_artifacts=step_artifacts) artifacted.subscribe(dispatcher) @@ -72,9 +93,35 @@ class Config(_Config): async def fire_arg_parsed_event(dispatcher: Dispatcher, *, - save_artifacts: bool = False, + save_artifacts: bool = Artifacted.save_artifacts, + add_artifact_details: bool = Artifacted.add_artifact_details, artifacts_dir: Optional[Path] = None) -> None: await dispatcher.fire(ArgParseEvent(ArgumentParser())) - namespace = Namespace(save_artifacts=save_artifacts, artifacts_dir=artifacts_dir) + namespace = Namespace( + save_artifacts=save_artifacts, + add_artifact_details=add_artifact_details, + artifacts_dir=artifacts_dir, + ) await dispatcher.fire(ArgParsedEvent(namespace)) + + +def patch_rmtree(exception: Optional[Exception] = None): + return patch("shutil.rmtree", side_effect=exception) + + +def patch_copy2(exception: Optional[Exception] = None): + return patch("shutil.copy2", side_effect=exception) + + +def patch_write_bytes(exception: Optional[Exception] = None): + return patch("pathlib.Path.write_bytes", side_effect=exception) + + +def patch_mkdir(exception: Optional[Exception] = None): + return patch("pathlib.Path.mkdir", side_effect=exception) + + +def make_artifacted_plugin(artifact_manager: ArtifactManager) -> ArtifactedPlugin: + return ArtifactedPlugin(Artifacted, + artifact_manager_factory=lambda *args: artifact_manager) diff --git a/tests/plugins/artifacted/test_artifact_manager.py b/tests/plugins/artifacted/test_artifact_manager.py new file mode 100644 index 00000000..8df6ee2f --- /dev/null +++ b/tests/plugins/artifacted/test_artifact_manager.py @@ -0,0 +1,187 @@ +from pathlib import Path +from typing import Type +from unittest.mock import call + +import pytest +from baby_steps import given, then, when +from pytest import raises + +from vedro.core import Artifact +from vedro.plugins.artifacted import ArtifactManager + +from ._utils import ( + artifact_manager, + artifacts_dir, + create_file_artifact, + create_memory_artifact, + patch_copy2, + patch_mkdir, + patch_rmtree, + patch_write_bytes, + project_dir, +) + +__all__ = ("project_dir", "artifacts_dir", "artifact_manager") # fixtures + + +def test_cleanup_artifacts(*, artifact_manager: ArtifactManager, artifacts_dir: Path): + with given: + artifacts_dir.mkdir(parents=True, exist_ok=True) + + with when: + artifact_manager.cleanup_artifacts() + + with then: + assert not artifacts_dir.exists() + + +def test_cleanup_artifacts_file_not_found(*, artifact_manager: ArtifactManager, + artifacts_dir: Path): + with given: + artifacts_dir.mkdir(parents=True, exist_ok=True) + + with when, patch_rmtree(FileNotFoundError()) as mock: + artifact_manager.cleanup_artifacts() + + with then: + # no exception raised + assert mock.mock_calls == [call(artifacts_dir)] + + +@pytest.mark.parametrize("exc_type", [PermissionError, OSError]) +def test_cleanup_artifacts_os_error(exc_type: Type[Exception], *, + artifact_manager: ArtifactManager, artifacts_dir: Path): + with given: + artifacts_dir.mkdir(parents=True, exist_ok=True) + + with when, \ + patch_rmtree(exc_type()) as mock, \ + raises(Exception) as exc: + artifact_manager.cleanup_artifacts() + + with then: + assert exc.type is exc_type + assert "Failed to clean up artifacts directory" in str(exc.value) + + assert mock.mock_calls == [call(artifacts_dir)] + + +def test_save_memory_artifact(*, artifact_manager: ArtifactManager, artifacts_dir: Path): + with given: + file_content = "Hello, World!" + artifact = create_memory_artifact(file_content) + + with when: + artifact_path = artifact_manager.save_artifact(artifact, artifacts_dir) + + with then: + assert artifact_path.exists() + assert artifact_path.read_bytes() == file_content.encode() + + +@pytest.mark.parametrize(("exc_type", "exc_msg"), [ + (PermissionError, "Permission denied when writing to"), + (OSError, "Failed to write MemoryArtifact to"), +]) +def test_save_memory_artifact_os_error(exc_type: Type[Exception], exc_msg: str, *, + artifact_manager: ArtifactManager, artifacts_dir: Path): + with given: + file_content = "Hello, World!" + artifact = create_memory_artifact(file_content) + + with when, \ + patch_write_bytes(exc_type()) as mock, \ + raises(Exception) as exc: + artifact_manager.save_artifact(artifact, artifacts_dir) + + with then: + assert exc.type is exc_type + assert exc_msg in str(exc.value) + + assert mock.mock_calls == [call(file_content.encode())] + + +def test_save_file_artifact(*, artifact_manager: ArtifactManager, + artifacts_dir: Path, tmp_path: Path): + with given: + file_path = tmp_path / "file_artifact.txt" + file_content = "Hello, World!" + artifact = create_file_artifact(file_path, file_content) + + with when: + artifact_path = artifact_manager.save_artifact(artifact, artifacts_dir) + + with then: + assert artifact_path.exists() + assert artifact_path.read_bytes() == file_content.encode() + + assert artifact.path.exists() + assert artifact.path.read_bytes() == file_content.encode() + + +@pytest.mark.parametrize(("exc_type", "exc_msg"), [ + (FileNotFoundError, "Source file"), + (PermissionError, "Permission denied when copying from "), + (OSError, "Failed to copy FileArtifact from"), +]) +def test_save_file_artifact_os_error(exc_type: Type[Exception], exc_msg: str, *, + artifact_manager: ArtifactManager, + artifacts_dir: Path, + tmp_path: Path): + with given: + file_path = tmp_path / "file_artifact.txt" + file_content = "Hello, World!" + artifact = create_file_artifact(file_path, file_content) + + with when, \ + patch_copy2(exc_type()) as mock, \ + raises(Exception) as exc: + artifact_manager.save_artifact(artifact, artifacts_dir) + + with then: + assert exc.type is exc_type + assert exc_msg in str(exc.value) + + assert artifact.path.exists() + assert artifact.path.read_bytes() == file_content.encode() + + assert mock.mock_calls == [call(artifact.path, artifacts_dir / artifact.name)] + + +@pytest.mark.parametrize("exc_type", [PermissionError, OSError]) +def test_save_artifact_directory_mkdir_failure(exc_type: Type[Exception], *, + artifact_manager: ArtifactManager, + artifacts_dir: Path): + with given: + artifacts_dir.rmdir() # artifacts_dir is automatically created by the fixture + + file_content = "Hello, World!" + artifact = create_memory_artifact(file_content) + + with when, \ + patch_mkdir(exc_type()) as mock, \ + raises(Exception) as exc: + artifact_manager.save_artifact(artifact, artifacts_dir) + + with then: + assert exc.type is exc_type + assert "Failed to create directory" in str(exc.value) + + assert mock.mock_calls == [call(parents=True, exist_ok=True)] + + +def test_save_artifact_unknown_type(*, artifact_manager: ArtifactManager, artifacts_dir: Path): + with given: + class UnknownArtifact(Artifact): + pass + + artifact = UnknownArtifact() + + with when, raises(BaseException) as exc: + artifact_manager.save_artifact(artifact, artifacts_dir) + + with then: + assert exc.type is TypeError + assert str(exc.value) == ( + f"Can't save artifact to '{artifacts_dir}': unknown type 'UnknownArtifact'" + ) diff --git a/tests/plugins/artifacted/test_artifacted_plugin.py b/tests/plugins/artifacted/test_artifacted_plugin.py index e9f75c3c..1db75d15 100644 --- a/tests/plugins/artifacted/test_artifacted_plugin.py +++ b/tests/plugins/artifacted/test_artifacted_plugin.py @@ -1,12 +1,15 @@ from collections import deque +from os import linesep from pathlib import Path +from unittest.mock import Mock, call import pytest from baby_steps import given, then, when from pytest import raises -from vedro.core import AggregatedResult, Dispatcher, ScenarioResult, StepResult +from vedro.core import AggregatedResult, Dispatcher, Report, ScenarioResult, StepResult from vedro.events import ( + CleanupEvent, ScenarioFailedEvent, ScenarioPassedEvent, ScenarioReportedEvent, @@ -14,22 +17,18 @@ StepFailedEvent, StepPassedEvent, ) -from vedro.plugins.artifacted import ( - Artifact, - Artifacted, - ArtifactedPlugin, - attach_artifact, - attach_scenario_artifact, - attach_step_artifact, -) +from vedro.plugins.artifacted import ArtifactManager from ._utils import ( artifacted, + artifacts_dir, create_file_artifact, create_memory_artifact, dispatcher, fire_arg_parsed_event, fire_config_loaded_event, + global_artifacts, + make_artifacted_plugin, make_vscenario, make_vstep, project_dir, @@ -37,34 +36,33 @@ step_artifacts, ) -__all__ = ("dispatcher", "scenario_artifacts", "step_artifacts", "artifacted", - "project_dir") # fixtures +__all__ = ("dispatcher", "global_artifacts", "scenario_artifacts", "step_artifacts", + "artifacted", "project_dir", "artifacts_dir") # fixtures -@pytest.mark.usefixtures(artifacted.__name__) async def test_arg_parsed_event_with_artifacts_dir_created(*, dispatcher: Dispatcher, - project_dir: Path): + project_dir: Path, + artifacts_dir: Path): with given: - await fire_config_loaded_event(dispatcher, project_dir) + artifact_manager_ = Mock(spec=ArtifactManager) + make_artifacted_plugin(artifact_manager_).subscribe(dispatcher) - artifacts_dir = project_dir / "artifacts/" - artifacts_dir.mkdir(exist_ok=True) + await fire_config_loaded_event(dispatcher, project_dir) with when: - await fire_arg_parsed_event(dispatcher, save_artifacts=True, artifacts_dir=artifacts_dir) + await fire_arg_parsed_event(dispatcher, artifacts_dir=artifacts_dir) with then: - assert artifacts_dir.exists() is False + assert artifact_manager_.mock_calls == [call.cleanup_artifacts()] @pytest.mark.usefixtures(artifacted.__name__) async def test_arg_parsed_event_error_on_disabled_artifact_saving(*, dispatcher: Dispatcher, - project_dir: Path): + project_dir: Path, + artifacts_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) @@ -84,7 +82,7 @@ async def test_arg_parsed_event_error_outside_artifacts_dir(*, dispatcher: Dispa artifacts_dir = Path("../artifacts") with when, raises(BaseException) as exc: - await fire_arg_parsed_event(dispatcher, save_artifacts=True, artifacts_dir=artifacts_dir) + await fire_arg_parsed_event(dispatcher, artifacts_dir=artifacts_dir) with then: assert exc.type is ValueError @@ -155,52 +153,55 @@ async def test_step_end_event_attaches_artifacts(event_class, *, dispatcher: Dis 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: + artifact_manager_ = Mock(spec=ArtifactManager) + make_artifacted_plugin(artifact_manager_).subscribe(dispatcher) + await fire_config_loaded_event(dispatcher, project_dir) - await fire_arg_parsed_event(dispatcher, save_artifacts=True) + await fire_arg_parsed_event(dispatcher) 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) + scenario_result.set_started_at(3.14) # started_at is used in _get_scenario_artifacts_dir + scenario_result.attach(artifact1 := create_memory_artifact()) + scenario_result.attach(artifact2 := create_file_artifact(project_dir / "test.txt")) aggregated_result = AggregatedResult.from_existing(scenario_result, [scenario_result]) event = ScenarioReportedEvent(aggregated_result) + artifact_manager_.reset_mock() + artifact_manager_.save_artifact = Mock( + side_effect=[ + Path(project_dir / artifact1.name), + Path(project_dir / artifact2.name) + ] + ) + 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" + assert artifact_manager_.mock_calls == [ + call.save_artifact(artifact1, scn_artifacts_path), + call.save_artifact(artifact2, scn_artifacts_path), + ] -@pytest.mark.usefixtures(artifacted.__name__) async def test_scenario_reported_event_saves_step_artifacts(*, dispatcher: Dispatcher, project_dir: Path): with given: + artifact_manager_ = Mock(spec=ArtifactManager) + make_artifacted_plugin(artifact_manager_).subscribe(dispatcher) + await fire_config_loaded_event(dispatcher, project_dir) - await fire_arg_parsed_event(dispatcher, save_artifacts=True) + await fire_arg_parsed_event(dispatcher) step_result = StepResult(make_vstep()) - artifact = create_memory_artifact(content := "text") - step_result.attach(artifact) + step_result.attach(artifact1 := create_memory_artifact()) + step_result.attach(artifact2 := create_file_artifact(project_dir / "test.txt")) scenario_result = ScenarioResult(make_vscenario()) scenario_result.set_started_at(3.14) @@ -209,77 +210,63 @@ async def test_scenario_reported_event_saves_step_artifacts(*, dispatcher: Dispa aggregated_result = AggregatedResult.from_existing(scenario_result, [scenario_result]) event = ScenarioReportedEvent(aggregated_result) + artifact_manager_.reset_mock() + artifact_manager_.save_artifact = Mock( + side_effect=[ + Path(project_dir / artifact1.name), + Path(project_dir / artifact2.name) + ] + ) + 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 + assert artifact_manager_.mock_calls == [ + call.save_artifact(artifact1, project_dir / scn_artifacts_path), + call.save_artifact(artifact2, project_dir / scn_artifacts_path), + ] -@pytest.mark.usefixtures(artifacted.__name__) -async def test_scenario_reported_event_incorrect_artifact(*, dispatcher: Dispatcher, - project_dir: Path): +async def test_cleanup_event_saves_global_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()) - artifact = type("NewArtifact", (Artifact,), {})() - scenario_result.attach(artifact) - - aggregated_result = AggregatedResult.from_existing(scenario_result, [scenario_result]) - event = ScenarioReportedEvent(aggregated_result) + artifact_manager_ = Mock(spec=ArtifactManager) + make_artifacted_plugin(artifact_manager_).subscribe(dispatcher) - 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'" + await fire_config_loaded_event(dispatcher, project_dir) + await fire_arg_parsed_event(dispatcher) + + report = Report() + report.attach(artifact1 := create_memory_artifact()) + report.attach(artifact2 := create_file_artifact(project_dir / "test.txt")) + + artifact_manager_.reset_mock() + artifact_manager_.save_artifact = Mock( + side_effect=[ + Path(project_dir / artifact1.name), + Path(project_dir / artifact2.name) + ] ) - -@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_memory_artifact() - attach_scenario_artifact(artifact) - - scenario_result = ScenarioResult(make_vscenario()) - event = event_class(scenario_result) - - with when: - await dispatcher.fire(event) - - with then: - assert scenario_result.artifacts == [artifact] - - -@pytest.mark.parametrize("attach", [attach_artifact, attach_step_artifact]) -@pytest.mark.parametrize("event_class", [StepPassedEvent, StepFailedEvent]) -async def test_attach_step_artifact(attach, event_class, *, dispatcher: Dispatcher): - with given: - artifacted = ArtifactedPlugin(Artifacted) - artifacted.subscribe(dispatcher) - - artifact = create_memory_artifact() - attach(artifact) - - step_result = StepResult(make_vstep()) - event = event_class(step_result) + event = CleanupEvent(report) with when: await dispatcher.fire(event) with then: - assert step_result.artifacts == [artifact] + global_artifacts_dir = project_dir / ".vedro/artifacts/global" + + assert artifact_manager_.mock_calls == [ + call.save_artifact(artifact1, project_dir / global_artifacts_dir), + call.save_artifact(artifact2, project_dir / global_artifacts_dir), + ] + + assert report.summary == [ + linesep.join([ + "global artifacts:", + f"# - {artifact1.name}", + f"# - {artifact2.name}", + ]) + ] diff --git a/tests/plugins/artifacted/test_attach_artifact.py b/tests/plugins/artifacted/test_attach_artifact.py new file mode 100644 index 00000000..9b2d0a11 --- /dev/null +++ b/tests/plugins/artifacted/test_attach_artifact.py @@ -0,0 +1,95 @@ +from pathlib import Path + +import pytest +from baby_steps import given, then, when + +from vedro.core import Dispatcher, Report, ScenarioResult, StepResult +from vedro.events import ( + CleanupEvent, + ScenarioFailedEvent, + ScenarioPassedEvent, + StepFailedEvent, + StepPassedEvent, +) +from vedro.plugins.artifacted import ( + Artifacted, + ArtifactedPlugin, + attach_artifact, + attach_global_artifact, + attach_scenario_artifact, + attach_step_artifact, +) + +from ._utils import ( + create_file_artifact, + create_memory_artifact, + dispatcher, + fire_arg_parsed_event, + fire_config_loaded_event, + make_vscenario, + make_vstep, + project_dir, +) + +__all__ = ("dispatcher", "project_dir",) # fixtures + + +@pytest.mark.parametrize("event_class", [ScenarioPassedEvent, ScenarioFailedEvent]) +async def test_attach_scenario_artifact(event_class, *, dispatcher: Dispatcher): + with given: + # The ArtifactedPlugin is created directly here to avoid injecting fixtures and + # focus on testing integration + artifacted = ArtifactedPlugin(Artifacted) + artifacted.subscribe(dispatcher) + + artifact = create_memory_artifact() + attach_scenario_artifact(artifact) + + scenario_result = ScenarioResult(make_vscenario()) + event = event_class(scenario_result) + + with when: + await dispatcher.fire(event) + + with then: + assert scenario_result.artifacts == [artifact] + + +@pytest.mark.parametrize("attach", [attach_artifact, attach_step_artifact]) +@pytest.mark.parametrize("event_class", [StepPassedEvent, StepFailedEvent]) +async def test_attach_step_artifact(attach, event_class, *, dispatcher: Dispatcher): + with given: + artifacted = ArtifactedPlugin(Artifacted) + artifacted.subscribe(dispatcher) + + artifact = create_memory_artifact() + attach(artifact) + + step_result = StepResult(make_vstep()) + event = event_class(step_result) + + with when: + await dispatcher.fire(event) + + with then: + assert step_result.artifacts == [artifact] + + +async def test_attach_global_artifact(dispatcher: Dispatcher, project_dir: Path): + with given: + artifacted = ArtifactedPlugin(Artifacted) + artifacted.subscribe(dispatcher) + + await fire_config_loaded_event(dispatcher, project_dir) + await fire_arg_parsed_event(dispatcher) + + attach_global_artifact(artifact1 := create_memory_artifact()) + attach_global_artifact(artifact2 := create_file_artifact(project_dir / "test.txt")) + + event = CleanupEvent(report := Report()) + + with when: + await dispatcher.fire(event) + + with then: + assert report.artifacts == [artifact1, artifact2] diff --git a/vedro/__init__.py b/vedro/__init__.py index 471bc8bd..de639b3d 100644 --- a/vedro/__init__.py +++ b/vedro/__init__.py @@ -14,6 +14,7 @@ FileArtifact, MemoryArtifact, attach_artifact, + attach_global_artifact, attach_scenario_artifact, attach_step_artifact, ) @@ -26,7 +27,8 @@ __all__ = ("Scenario", "Interface", "run", "only", "skip", "skip_if", "params", "ensure", "context", "defer", "defer_global", "Config", "catched", "create_tmp_dir", "create_tmp_file", "attach_artifact", "attach_scenario_artifact", - "attach_step_artifact", "MemoryArtifact", "FileArtifact", "Artifact",) + "attach_step_artifact", "attach_global_artifact", "MemoryArtifact", "FileArtifact", + "Artifact",) def run(*, plugins: Any = None) -> None: diff --git a/vedro/core/_report.py b/vedro/core/_report.py index 60656255..633ef72c 100644 --- a/vedro/core/_report.py +++ b/vedro/core/_report.py @@ -1,13 +1,26 @@ from typing import Any, List, Union, cast -from ._exc_info import ExcInfo -from .scenario_result import AggregatedResult +from vedro.core._artifacts import Artifact +from vedro.core._exc_info import ExcInfo +from vedro.core._scenario_result import AggregatedResult __all__ = ("Report",) class Report: + """ + Represents a report for test execution results. + + This class aggregates results from multiple test scenarios, tracks execution + statistics such as total, passed, failed, skipped scenarios, and provides + summary information. It also handles execution timing, artifacts, and + interruptions. + """ + def __init__(self) -> None: + """ + Initialize a Report instance with default values. + """ self._summary: List[str] = [] self._started_at: Union[float, None] = None self._ended_at: Union[float, None] = None @@ -16,46 +29,100 @@ def __init__(self) -> None: self._failed: int = 0 self._skipped: int = 0 self._interrupted: Union[ExcInfo, None] = None + self._artifacts: List[Artifact] = [] @property def interrupted(self) -> Union[ExcInfo, None]: + """ + Retrieve information about any interruption that occurred. + + :return: The exception information if interrupted, otherwise None. + """ return self._interrupted @property def started_at(self) -> Union[float, None]: + """ + Retrieve the timestamp when the first scenario started. + + :return: The start time as a float or None if not set. + """ return self._started_at @property def ended_at(self) -> Union[float, None]: + """ + Retrieve the timestamp when the last scenario ended. + + :return: The end time as a float or None if not set. + """ return self._ended_at @property def total(self) -> int: + """ + Retrieve the total number of scenarios executed. + + :return: The total number of scenarios. + """ return self._total @property def passed(self) -> int: + """ + Retrieve the number of scenarios that passed. + + :return: The count of passed scenarios. + """ return self._passed @property def failed(self) -> int: + """ + Retrieve the number of scenarios that failed. + + :return: The count of failed scenarios. + """ return self._failed @property def skipped(self) -> int: + """ + Retrieve the number of scenarios that were skipped. + + :return: The count of skipped scenarios. + """ return self._skipped @property def summary(self) -> List[str]: + """ + Retrieve the summary information for the report. + + :return: A shallow copy of the summary list. + """ return self._summary[:] @property def elapsed(self) -> float: + """ + Calculate the total elapsed time for all scenarios. + + :return: The elapsed time in seconds, or 0.0 if timing information is not set. + """ if (self.ended_at is None) or (self.started_at is None): return 0.0 return self.ended_at - self.started_at def add_result(self, result: AggregatedResult) -> None: + """ + Add the result of a scenario to the report. + + Updates the total, passed, failed, or skipped counts based on the scenario status. + Adjusts the overall start and end times based on the scenario's timing. + + :param result: The aggregated result of a scenario. + """ self._total += 1 if result.is_passed(): self._passed += 1 @@ -75,15 +142,59 @@ def add_result(self, result: AggregatedResult) -> None: self._ended_at = max(cast(float, self._ended_at), result.ended_at) def add_summary(self, summary: str) -> None: + """ + Add a summary entry to the report. + + :param summary: A string containing summary information. + """ self._summary.append(summary) + def attach(self, artifact: Artifact) -> None: + """ + Attach an artifact to the report. + + :param artifact: The artifact to attach. + :raises TypeError: If the provided artifact is not an instance of Artifact. + """ + if not isinstance(artifact, Artifact): + raise TypeError( + f"Expected an instance of Artifact, got {type(artifact).__name__}" + ) + self._artifacts.append(artifact) + + @property + def artifacts(self) -> List[Artifact]: + """ + Retrieve the list of attached artifacts. + + :return: A shallow copy of the artifacts list. + """ + # In v2, this will return a tuple instead of a list + return self._artifacts[:] + def set_interrupted(self, exc_info: ExcInfo) -> None: + """ + Set the exception information for an interruption. + + :param exc_info: The exception information related to the interruption. + """ self._interrupted = exc_info def __eq__(self, other: Any) -> bool: + """ + Check equality with another Report instance. + + :param other: The object to compare. + :return: True if the instances are equal, False otherwise. + """ return isinstance(other, self.__class__) and (self.__dict__ == other.__dict__) def __repr__(self) -> str: + """ + Return a string representation of the Report instance. + + :return: A string containing the report's totals and interruption status. + """ interrupted = self.interrupted.type if self.interrupted else None return (f"<{self.__class__.__name__} " f"total={self._total} passed={self._passed} " diff --git a/vedro/core/_step_result.py b/vedro/core/_step_result.py index 1a8afa96..51eda0ff 100644 --- a/vedro/core/_step_result.py +++ b/vedro/core/_step_result.py @@ -27,7 +27,19 @@ class StepStatus(Enum): class StepResult: + """ + Represents the result of a step execution. + + This class manages the state and outcome of a test step, including its status, + timing, associated artifacts, exceptions, and additional details. + """ + def __init__(self, step: VirtualStep) -> None: + """ + Initialize a StepResult instance for the given step. + + :param step: The virtual step for which the result is tracked. + """ self._step = step self._status: StepStatus = StepStatus.PENDING self._started_at: Union[float, None] = None @@ -38,84 +50,196 @@ def __init__(self, step: VirtualStep) -> None: @property def step(self) -> VirtualStep: + """ + Retrieve the virtual step associated with this result. + + :return: The virtual step object. + """ return self._step @property def step_name(self) -> str: + """ + Retrieve the name of the step. + + :return: The name of the step as a string. + """ return self._step.name @property def status(self) -> StepStatus: + """ + Retrieve the current status of the step. + + :return: The current step status. + """ return self._status def is_passed(self) -> bool: + """ + Check if the step is marked as passed. + + :return: True if the step is passed, False otherwise. + """ return self._status == StepStatus.PASSED def is_failed(self) -> bool: + """ + Check if the step is marked as failed. + + :return: True if the step is failed, False otherwise. + """ return self._status == StepStatus.FAILED def mark_failed(self) -> "StepResult": + """ + Mark the step as failed. + + :return: The StepResult instance for chaining. + :raises RuntimeError: If the step status has already been set. + """ if self._status != StepStatus.PENDING: raise RuntimeError( - "Cannot mark step as failed because its status has already been set") + "Cannot mark step as failed because its status has already been set" + ) self._status = StepStatus.FAILED return self def mark_passed(self) -> "StepResult": + """ + Mark the step as passed. + + :return: The StepResult instance for chaining. + :raises RuntimeError: If the step status has already been set. + """ if self._status != StepStatus.PENDING: raise RuntimeError( - "Cannot mark step as passed because its status has already been set") + "Cannot mark step as passed because its status has already been set" + ) self._status = StepStatus.PASSED return self @property def started_at(self) -> Union[float, None]: + """ + Retrieve the timestamp when the step started. + + :return: The start time as a float or None if not set. + """ return self._started_at def set_started_at(self, started_at: float) -> "StepResult": + """ + Set the start timestamp for the step. + + :param started_at: The start time as a float. + :return: The StepResult instance for chaining. + """ self._started_at = started_at return self @property def ended_at(self) -> Union[float, None]: + """ + Retrieve the timestamp when the step ended. + + :return: The end time as a float or None if not set. + """ return self._ended_at def set_ended_at(self, ended_at: float) -> "StepResult": + """ + Set the end timestamp for the step. + + :param ended_at: The end time as a float. + :return: The StepResult instance for chaining. + """ self._ended_at = ended_at return self @property def elapsed(self) -> float: + """ + Calculate the elapsed time for the step. + + :return: The elapsed time in seconds, or 0.0 if the start or end time is not set. + """ if (self._started_at is None) or (self._ended_at is None): return 0.0 return self._ended_at - self._started_at @property def exc_info(self) -> Union[ExcInfo, None]: + """ + Retrieve exception information associated with the step. + + :return: The exception information object, or None if no exception occurred. + """ return self._exc_info def set_exc_info(self, exc_info: ExcInfo) -> "StepResult": + """ + Set the exception information for the step. + + :param exc_info: The exception information object. + :return: The StepResult instance for chaining. + """ self._exc_info = exc_info return self def attach(self, artifact: Artifact) -> None: + """ + Attach an artifact to the step. + + :param artifact: The artifact to attach. + :raises TypeError: If the provided artifact is not an instance of Artifact. + """ if not isinstance(artifact, Artifact): - raise TypeError("artifact must be an instance of Artifact") + raise TypeError( + f"Expected an instance of Artifact, got {type(artifact).__name__}" + ) self._artifacts.append(artifact) @property def artifacts(self) -> List[Artifact]: + """ + Retrieve the list of attached artifacts. + + :return: A shallow copy of the artifacts list. + """ + # In v2, this will return a tuple instead of a list return self._artifacts[:] def add_extra_details(self, extra: str) -> None: + """ + Add extra details related to the step. + + :param extra: A string containing additional details. + """ self._extra_details.append(extra) @property def extra_details(self) -> List[str]: + """ + Retrieve the list of extra details. + + :return: A shallow copy of the extra details list. + """ return self._extra_details[:] def __repr__(self) -> str: + """ + Return a string representation of the StepResult instance. + + :return: A string containing the class name, step, and status. + """ return f"<{self.__class__.__name__} {self._step!r} {self._status.value}>" def __eq__(self, other: Any) -> bool: + """ + Check equality with another StepResult instance. + + :param other: The object to compare. + :return: True if the instances are equal, False otherwise. + """ return isinstance(other, self.__class__) and (self.__dict__ == other.__dict__) diff --git a/vedro/core/scenario_result/_scenario_result.py b/vedro/core/scenario_result/_scenario_result.py index 3475a09e..23921718 100644 --- a/vedro/core/scenario_result/_scenario_result.py +++ b/vedro/core/scenario_result/_scenario_result.py @@ -12,7 +12,21 @@ class ScenarioResult: + """ + Represents the result of a scenario execution. + + This class manages the state and outcome of a scenario, including its status, + timing, step results, scope, artifacts, and additional details. It provides + methods to update the status of the scenario, track its execution time, and + store related data such as artifacts and extra details. + """ + def __init__(self, scenario: VirtualScenario) -> None: + """ + Initialize a ScenarioResult instance for the given scenario. + + :param scenario: The virtual scenario for which the result is tracked. + """ self._scenario = scenario self._status: ScenarioStatus = ScenarioStatus.PENDING self._started_at: Union[float, None] = None @@ -24,98 +38,226 @@ def __init__(self, scenario: VirtualScenario) -> None: @property def scenario(self) -> VirtualScenario: + """ + Retrieve the virtual scenario associated with this result. + + :return: The virtual scenario object. + """ return self._scenario @property def status(self) -> ScenarioStatus: + """ + Retrieve the current status of the scenario. + + :return: The current scenario status. + """ return self._status def mark_passed(self) -> "ScenarioResult": + """ + Mark the scenario as passed. + + :return: The ScenarioResult instance for chaining. + :raises RuntimeError: If the scenario status has already been set. + """ if self.status != ScenarioStatus.PENDING: raise RuntimeError( - "Cannot mark scenario as passed because its status has already been set") + "Cannot mark scenario as passed because its status has already been set" + ) self._status = ScenarioStatus.PASSED return self def is_passed(self) -> bool: + """ + Check if the scenario is marked as passed. + + :return: True if the scenario is passed, False otherwise. + """ return self._status == ScenarioStatus.PASSED def mark_failed(self) -> "ScenarioResult": + """ + Mark the scenario as failed. + + :return: The ScenarioResult instance for chaining. + :raises RuntimeError: If the scenario status has already been set. + """ if self.status != ScenarioStatus.PENDING: raise RuntimeError( - "Cannot mark scenario as failed because its status has already been set") + "Cannot mark scenario as failed because its status has already been set" + ) self._status = ScenarioStatus.FAILED return self def is_failed(self) -> bool: + """ + Check if the scenario is marked as failed. + + :return: True if the scenario is failed, False otherwise. + """ return self._status == ScenarioStatus.FAILED def mark_skipped(self) -> "ScenarioResult": + """ + Mark the scenario as skipped. + + :return: The ScenarioResult instance for chaining. + :raises RuntimeError: If the scenario status has already been set. + """ if self.status != ScenarioStatus.PENDING: raise RuntimeError( - "Cannot mark scenario as skipped because its status has already been set") + "Cannot mark scenario as skipped because its status has already been set" + ) self._status = ScenarioStatus.SKIPPED return self def is_skipped(self) -> bool: + """ + Check if the scenario is marked as skipped. + + :return: True if the scenario is skipped, False otherwise. + """ return self._status == ScenarioStatus.SKIPPED @property def started_at(self) -> Union[float, None]: + """ + Retrieve the timestamp when the scenario started. + + :return: The start time as a float or None if not set. + """ return self._started_at def set_started_at(self, started_at: float) -> "ScenarioResult": + """ + Set the start timestamp for the scenario. + + :param started_at: The start time as a float. + :return: The ScenarioResult instance for chaining. + """ self._started_at = started_at return self @property def ended_at(self) -> Union[float, None]: + """ + Retrieve the timestamp when the scenario ended. + + :return: The end time as a float or None if not set. + """ return self._ended_at def set_ended_at(self, ended_at: float) -> "ScenarioResult": + """ + Set the end timestamp for the scenario. + + :param ended_at: The end time as a float. + :return: The ScenarioResult instance for chaining. + """ self._ended_at = ended_at return self @property def elapsed(self) -> float: + """ + Calculate the elapsed time for the scenario. + + :return: The elapsed time in seconds, or 0.0 if the start or end time is not set. + """ if (self._started_at is None) or (self._ended_at is None): return 0.0 return self._ended_at - self._started_at def add_step_result(self, step_result: StepResult) -> None: + """ + Add a step result to the scenario. + + :param step_result: The step result to add. + """ self._step_results.append(step_result) @property def step_results(self) -> List[StepResult]: + """ + Retrieve the list of step results. + + :return: A shallow copy of the step results list. + """ return self._step_results[:] def set_scope(self, scope: ScopeType) -> None: + """ + Set the execution scope for the scenario. + + :param scope: A dictionary representing the execution scope. + """ self._scope = scope @property def scope(self) -> ScopeType: + """ + Retrieve the execution scope for the scenario. + + :return: A dictionary representing the scope, or an empty dictionary if not set. + """ if self._scope is None: return {} return self._scope def attach(self, artifact: Artifact) -> None: + """ + Attach an artifact to the scenario. + + :param artifact: The artifact to attach. + :raises TypeError: If the provided artifact is not an instance of Artifact. + """ if not isinstance(artifact, Artifact): - raise TypeError("artifact must be an instance of Artifact") + raise TypeError( + f"Expected an instance of Artifact, got {type(artifact).__name__}" + ) self._artifacts.append(artifact) @property def artifacts(self) -> List[Artifact]: + """ + Retrieve the list of attached artifacts. + + :return: A shallow copy of the artifacts list. + """ + # In v2, this will return a tuple instead of a list return self._artifacts[:] def add_extra_details(self, extra: str) -> None: + """ + Add extra details related to the scenario. + + :param extra: A string containing additional details. + """ self._extra_details.append(extra) @property def extra_details(self) -> List[str]: + """ + Retrieve the list of extra details. + + :return: A shallow copy of the extra details list. + """ return self._extra_details[:] def __repr__(self) -> str: + """ + Return a string representation of the ScenarioResult instance. + + :return: A string containing the class name, scenario, and status. + """ return f"<{self.__class__.__name__} {self._scenario!r} {self._status.value}>" def __eq__(self, other: Any) -> bool: + """ + Check equality with another ScenarioResult instance. + + :param other: The object to compare. + :return: True if the instances are equal, False otherwise. + """ return isinstance(other, self.__class__) and (self.__dict__ == other.__dict__) diff --git a/vedro/plugins/artifacted/__init__.py b/vedro/plugins/artifacted/__init__.py index 0d4ef688..856837c2 100644 --- a/vedro/plugins/artifacted/__init__.py +++ b/vedro/plugins/artifacted/__init__.py @@ -1,13 +1,15 @@ from vedro.core import Artifact, FileArtifact, MemoryArtifact +from ._artifact_manager import ArtifactManager from ._artifacted import ( Artifacted, ArtifactedPlugin, attach_artifact, + attach_global_artifact, attach_scenario_artifact, attach_step_artifact, ) -__all__ = ("Artifacted", "ArtifactedPlugin", - "attach_artifact", "attach_step_artifact", "attach_scenario_artifact", - "Artifact", "MemoryArtifact", "FileArtifact",) +__all__ = ("Artifacted", "ArtifactedPlugin", "attach_artifact", "attach_step_artifact", + "attach_scenario_artifact", "attach_global_artifact", "Artifact", + "MemoryArtifact", "FileArtifact", "ArtifactManager",) diff --git a/vedro/plugins/artifacted/_artifact_manager.py b/vedro/plugins/artifacted/_artifact_manager.py new file mode 100644 index 00000000..fce55b7e --- /dev/null +++ b/vedro/plugins/artifacted/_artifact_manager.py @@ -0,0 +1,175 @@ +import shutil +from os import linesep +from pathlib import Path +from typing import Callable, Type, Union + +from vedro.core import Artifact, FileArtifact, MemoryArtifact + +__all__ = ("ArtifactManager", "ArtifactManagerFactory",) + + +class ArtifactManager: + """ + Manages the creation, storage, and cleanup of artifacts. + + This class provides functionality to handle artifacts in the form of memory and file-based + objects. It ensures proper directory structure, saves artifacts to a specified location, + and handles cleanup operations for artifacts directories. + """ + + def __init__(self, artifacts_dir: Path, project_dir: Path) -> None: + """ + Initialize the ArtifactManager with the specified directories. + + :param artifacts_dir: The directory where artifacts will be stored. + :param project_dir: The base project directory, used to resolve relative paths + for file artifacts. + """ + self._artifacts_dir = artifacts_dir + self._project_dir = project_dir + + def cleanup_artifacts(self) -> None: + """ + Remove all files and directories within the artifacts directory. + + Deletes the artifacts directory and its contents if it exists. Handles cases where + the directory does not exist, or where permissions or other OS errors occur. + + :raises PermissionError: If the directory cannot be deleted due to permissions issues. + :raises OSError: If an unexpected OS error occurs while deleting the directory. + """ + if not self._artifacts_dir.exists(): + return + + try: + shutil.rmtree(self._artifacts_dir) + except FileNotFoundError: + # The directory was deleted between the check and the rmtree call + pass + except PermissionError as e: + raise self._make_permissions_error( + f"Failed to clean up artifacts directory '{self._artifacts_dir}'." + ) from e + except OSError as e: + raise OSError( + f"Failed to clean up artifacts directory '{self._artifacts_dir}': {e}" + ) from e + + def save_artifact(self, artifact: Artifact, path: Path) -> Path: + """ + Save an artifact to the specified path. + + Depending on the type of artifact, this method saves either a memory-based artifact + or a file-based artifact. Ensures that the target directory exists before saving. + + :param artifact: The artifact to save, which can be a MemoryArtifact or FileArtifact. + :param path: The directory where the artifact should be saved. + :return: The path to the saved artifact. + :raises TypeError: If the artifact type is unknown. + :raises PermissionError: If the directory or file cannot be created due to + permissions issues. + :raises OSError: If an unexpected OS error occurs during the save operation. + """ + self._ensure_directory_exists(path) + + if isinstance(artifact, MemoryArtifact): + return self._save_memory_artifact(artifact, path) + elif isinstance(artifact, FileArtifact): + return self._save_file_artifact(artifact, path) + else: + artifact_type = type(artifact).__name__ + message = f"Can't save artifact to '{path}': unknown type '{artifact_type}'" + raise TypeError(message) + + def _ensure_directory_exists(self, path: Path) -> None: + """ + Ensure that the specified directory exists, creating it if necessary. + + :param path: The directory to check or create. + :raises PermissionError: If the directory cannot be created due to permissions issues. + :raises OSError: If an unexpected OS error occurs while creating the directory. + """ + if not path.exists(): + try: + path.mkdir(parents=True, exist_ok=True) + except PermissionError as e: + raise self._make_permissions_error(f"Failed to create directory '{path}'.") from e + except OSError as e: + raise OSError(f"Failed to create directory '{path}': {e}") from e + + def _save_memory_artifact(self, artifact: MemoryArtifact, path: Path) -> Path: + """ + Save a MemoryArtifact to the specified path. + + Writes the binary data from the MemoryArtifact to a file. + + :param artifact: The MemoryArtifact to save. + :param path: The directory where the artifact should be saved. + :return: The path to the saved artifact. + :raises PermissionError: If writing to the file is denied. + :raises OSError: If an unexpected OS error occurs while writing the file. + """ + artifact_dest = (path / artifact.name).resolve() + try: + artifact_dest.write_bytes(artifact.data) + except PermissionError as e: + raise self._make_permissions_error( + f"Permission denied when writing to '{artifact_dest}'." + ) from e + except OSError as e: + raise OSError(f"Failed to write MemoryArtifact to '{artifact_dest}': {e}") from e + else: + return artifact_dest + + def _save_file_artifact(self, artifact: FileArtifact, path: Path) -> Path: + """ + Save a FileArtifact to the specified path. + + Copies the source file from the artifact's path to the target directory. + + :param artifact: The FileArtifact to save. + :param path: The directory where the artifact should be saved. + :return: The path to the saved artifact. + :raises FileNotFoundError: If the source file does not exist. + :raises PermissionError: If copying the file is denied. + :raises OSError: If an unexpected OS error occurs while copying the file. + """ + artifact_dest = (path / artifact.name).resolve() + artifact_source = artifact.path + if not artifact_source.is_absolute(): + artifact_source = (self._project_dir / artifact_source).resolve() + try: + shutil.copy2(artifact_source, artifact_dest) + except FileNotFoundError as e: + raise FileNotFoundError(f"Source file '{artifact_source}' not found: {e}") from e + except PermissionError as e: + raise self._make_permissions_error( + f"Permission denied when copying from '{artifact_source}' to '{artifact_dest}'." + ) from e + except OSError as e: + raise OSError( + f"Failed to copy FileArtifact from '{artifact_source}' to '{artifact_dest}': {e}" + ) from e + else: + return artifact_dest + + def _make_permissions_error(self, failure_message: str) -> PermissionError: + """ + Create a detailed PermissionError with resolution suggestions. + + :param failure_message: The main error message explaining the failure. + :return: A PermissionError instance with additional context. + """ + return PermissionError(linesep.join([ + failure_message, + "To resolve this issue, you can:", + "- Adjust the directory permissions to allow write access.", + "- Change the target directory to one with the appropriate permissions.", + "- Disable saving artifacts." + ])) + + +ArtifactManagerFactory = Union[ + Type[ArtifactManager], + Callable[[Path, Path], ArtifactManager] +] diff --git a/vedro/plugins/artifacted/_artifacted.py b/vedro/plugins/artifacted/_artifacted.py index 2a83fe69..bf5a0895 100644 --- a/vedro/plugins/artifacted/_artifacted.py +++ b/vedro/plugins/artifacted/_artifacted.py @@ -1,5 +1,5 @@ -import shutil from collections import deque +from os import linesep from pathlib import Path from typing import Deque, Type, Union, final @@ -7,8 +7,6 @@ Artifact, ConfigType, Dispatcher, - FileArtifact, - MemoryArtifact, Plugin, PluginConfig, ScenarioResult, @@ -17,21 +15,27 @@ from vedro.events import ( ArgParsedEvent, ArgParseEvent, + CleanupEvent, ConfigLoadedEvent, ScenarioFailedEvent, ScenarioPassedEvent, ScenarioReportedEvent, ScenarioRunEvent, + StartupEvent, StepFailedEvent, StepPassedEvent, ) -__all__ = ("Artifacted", "ArtifactedPlugin", - "attach_artifact", "attach_step_artifact", "attach_scenario_artifact",) +from ._artifact_manager import ArtifactManager, ArtifactManagerFactory +from ._utils import is_relative_to + +__all__ = ("Artifacted", "ArtifactedPlugin", "attach_artifact", "attach_step_artifact", + "attach_scenario_artifact", "attach_global_artifact") _scenario_artifacts: Deque[Artifact] = deque() _step_artifacts: Deque[Artifact] = deque() +_global_artifacts: Deque[Artifact] = deque() def attach_scenario_artifact(artifact: Artifact) -> None: @@ -61,6 +65,15 @@ def attach_artifact(artifact: Artifact) -> None: attach_step_artifact(artifact) +def attach_global_artifact(artifact: Artifact) -> None: + """ + Attach an artifact to the entire test run. + + :param artifact: The artifact to be attached globally. + """ + _global_artifacts.append(artifact) + + @final class ArtifactedPlugin(Plugin): """ @@ -71,22 +84,30 @@ class ArtifactedPlugin(Plugin): """ def __init__(self, config: Type["Artifacted"], *, + artifact_manager_factory: ArtifactManagerFactory = ArtifactManager, + global_artifacts: Deque[Artifact] = _global_artifacts, scenario_artifacts: Deque[Artifact] = _scenario_artifacts, step_artifacts: Deque[Artifact] = _step_artifacts) -> None: """ - Initialize the ArtifactedPlugin with the provided configuration. + Initialize the ArtifactedPlugin instance with configuration and artifact queues. - :param config: The Artifacted configuration class. - :param scenario_artifacts: The deque holding scenario artifacts. - :param step_artifacts: The deque holding step artifacts. + :param config: The Artifacted plugin configuration. + :param artifact_manager_factory: A factory for creating ArtifactManager instances. + :param global_artifacts: A deque to store global artifacts. + :param scenario_artifacts: A deque to store scenario artifacts. + :param step_artifacts: A deque to store step artifacts. """ super().__init__(config) + self._artifact_manager_factory = artifact_manager_factory + self._global_artifacts = global_artifacts 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._cleanup_artifacts_dir = config.cleanup_artifacts_dir self._global_config: Union[ConfigType, None] = None + self._artifact_manager: Union[ArtifactManager, None] = None def subscribe(self, dispatcher: Dispatcher) -> None: """ @@ -97,12 +118,14 @@ def subscribe(self, dispatcher: Dispatcher) -> None: dispatcher.listen(ConfigLoadedEvent, self.on_config_loaded) \ .listen(ArgParseEvent, self.on_arg_parse) \ .listen(ArgParsedEvent, self.on_arg_parsed) \ + .listen(StartupEvent, self.on_startup) \ .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(ScenarioReportedEvent, self.on_scenario_reported) + .listen(ScenarioReportedEvent, self.on_scenario_reported) \ + .listen(CleanupEvent, self.on_cleanup) def on_config_loaded(self, event: ConfigLoadedEvent) -> None: """ @@ -120,37 +143,68 @@ def on_arg_parse(self, event: ArgParseEvent) -> None: """ group = event.arg_parser.add_argument_group("Artifacted") - group.add_argument("-a", "--save-artifacts", action="store_true", - default=self._save_artifacts, - help="Save artifacts to the file system") + save_artifacts_group = group.add_mutually_exclusive_group() + save_artifacts_group.add_argument("--save-artifacts", + action="store_true", + default=self._save_artifacts, + help="Save artifacts to the file system") + save_artifacts_group.add_argument("--no-save-artifacts", + dest="save_artifacts", + action="store_false", + help="Disable saving 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}')")) + add_details_group = group.add_mutually_exclusive_group() + add_details_group.add_argument("--add-artifact-details", action="store_true", + default=self._add_artifact_details, + help="Add artifact details to scenario and step extras") + add_details_group.add_argument("--no-add-artifact-details", action="store_false", + dest="add_artifact_details", + help=("Disable adding artifact details to scenario and " + "step extras")) + def on_arg_parsed(self, event: ArgParsedEvent) -> None: """ Handle the event after arguments have been parsed, processing artifact options. :param event: The ArgParsedEvent instance containing parsed arguments. - :raises ValueError: If artifacts directory is specified but saving is disabled. + :raises ValueError: If invalid argument combinations are provided. """ + self._add_artifact_details = event.args.add_artifact_details 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") + if self._add_artifact_details: + raise ValueError( + "Adding artifact details requires artifact saving to be enabled") 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): + if not 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) + self._artifact_manager = self._artifact_manager_factory(self._artifacts_dir, + self._get_project_dir()) + if self._cleanup_artifacts_dir: + self._artifact_manager.cleanup_artifacts() + + def on_startup(self, event: StartupEvent) -> None: + """ + Handle the event when the test run starts, clearing global artifacts. + + :param event: The StartupEvent instance. + """ + self._global_artifacts.clear() def on_scenario_run(self, event: ScenarioRunEvent) -> None: """ @@ -191,33 +245,48 @@ async def on_scenario_reported(self, event: ScenarioReportedEvent) -> None: if not self._save_artifacts: return + assert self._artifact_manager is not None # for type checker + 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) + artifact_path = self._artifact_manager.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) + artifact_path = self._artifact_manager.save_artifact(artifact, + scenario_artifacts_dir) self._add_extra_details(scenario_result, artifact_path) - def _is_relative_to(self, path: Path, parent: Path) -> bool: + async def on_cleanup(self, event: CleanupEvent) -> None: """ - Check if the given path is relative to the specified parent directory. + Handle the cleanup event, saving and summarizing global artifacts if configured. - :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. + :param event: The CleanupEvent instance. """ - try: - path.relative_to(parent) - except ValueError: - return False - else: - return path != parent + if not self._save_artifacts: + return + + assert self._artifact_manager is not None # for type checker + + while len(self._global_artifacts) > 0: + artifact = self._global_artifacts.popleft() + event.report.attach(artifact) + + global_artifacts_dir = self._get_global_artifacts_dir() + artifacts = [] + for artifact in event.report.artifacts: + artifact_path = self._artifact_manager.save_artifact(artifact, global_artifacts_dir) + artifacts.append(self._get_rel_path(artifact_path)) + + if self._add_artifact_details and len(artifacts) > 0: + sep = f"{linesep}# - " + summary = f"global artifacts:{sep}" + f"{sep}".join(str(x) for x in artifacts) + event.report.add_summary(summary) def _add_extra_details(self, result: Union[ScenarioResult, StepResult], artifact_path: Path) -> None: @@ -228,9 +297,18 @@ def _add_extra_details(self, result: Union[ScenarioResult, StepResult], :param artifact_path: The file path where the artifact was saved. """ if self._add_artifact_details: - rel_path = artifact_path.relative_to(self._get_project_dir()) + rel_path = self._get_rel_path(artifact_path) result.add_extra_details(f"artifact '{rel_path}'") + def _get_rel_path(self, path: Path) -> Path: + """ + Get the relative path of a given path with respect to the project directory. + + :param path: The path to be converted to a relative path. + :return: The relative Path. + """ + return path.relative_to(self._get_project_dir()) + def _get_project_dir(self) -> Path: """ Get the project's root directory. @@ -240,6 +318,14 @@ def _get_project_dir(self) -> Path: assert self._global_config is not None # for type checker return self._global_config.project_dir.resolve() + def _get_global_artifacts_dir(self) -> Path: + """ + Get the directory path where global artifacts should be stored. + + :return: The Path to the directory for global artifacts. + """ + return self._artifacts_dir / "global" + def _get_scenario_artifacts_dir(self, scenario_result: ScenarioResult) -> Path: """ Get the directory path where artifacts for a scenario should be stored. @@ -256,36 +342,6 @@ def _get_scenario_artifacts_dir(self, scenario_result: ScenarioResult) -> Path: return scenario_path - def _save_artifact(self, artifact: Artifact, scenario_path: Path) -> Path: - """ - Save an artifact to the file system. - - :param artifact: The artifact to be saved. - :param scenario_path: The directory path where the artifact should be saved. - :return: The Path to the saved artifact. - :raises TypeError: If the artifact type is unknown. - """ - 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): """ @@ -298,13 +354,20 @@ class Artifacted(PluginConfig): plugin = ArtifactedPlugin description = "Manages artifacts for step and scenario results" - # Save artifacts to the file system - save_artifacts: bool = False + # Enable or disable saving artifacts to the file system. + # If False, artifacts will not be saved, and `artifacts_dir` cannot be specified. + save_artifacts: bool = True - # Directory path for saving artifacts - # Available if `save_artifacts` is True + # Directory path where artifacts will be saved. + # This option is only applicable if `save_artifacts` is set to True. + # If unspecified, the default directory is ".vedro/artifacts/". artifacts_dir: Path = Path(".vedro/artifacts/") - # Add artifact details to scenario and steps extras - # Available if `save_artifacts` is True + # Enable or disable adding artifact details to scenario and step extras. + # This option is only applicable if `save_artifacts` is set to True. + # If `save_artifacts` is False and this is True, a ValueError will be raised. add_artifact_details: bool = True + + # Enable or disable cleanup of the artifacts directory before starting the test run. + # If True, the artifacts directory will be removed at the start of the test run. + cleanup_artifacts_dir: bool = True diff --git a/vedro/plugins/artifacted/_utils.py b/vedro/plugins/artifacted/_utils.py new file mode 100644 index 00000000..d394bf5c --- /dev/null +++ b/vedro/plugins/artifacted/_utils.py @@ -0,0 +1,19 @@ +from pathlib import Path + +__all__ = ("is_relative_to",) + + +def is_relative_to(path: Path, parent: Path) -> bool: + """ + Check if the given path is relative to 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: + return False + else: + return path != parent