|
13 | 13 | from dataclasses import dataclass, field
|
14 | 14 | from datetime import datetime, timedelta
|
15 | 15 | from enum import Enum
|
16 |
| -from typing import TYPE_CHECKING, Any, ClassVar, List, Optional, Set |
| 16 | +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterable, List, Optional, Set |
17 | 17 |
|
18 | 18 | import anyio
|
19 | 19 | import asyncer
|
|
22 | 22 | from dagger import Container, DaggerError
|
23 | 23 | from jinja2 import Environment, PackageLoader, select_autoescape
|
24 | 24 | from pipelines import sentry_utils
|
25 |
| -from pipelines.actions import remote_storage |
| 25 | +from pipelines.actions import environments, remote_storage |
26 | 26 | from pipelines.consts import GCS_PUBLIC_DOMAIN, LOCAL_REPORTS_PATH_ROOT, PYPROJECT_TOML_FILE_PATH
|
27 |
| -from pipelines.utils import METADATA_FILE_NAME, check_path_in_workdir, format_duration, get_exec_result |
| 27 | +from pipelines.utils import METADATA_FILE_NAME, format_duration, get_exec_result |
28 | 28 | from rich.console import Group
|
29 | 29 | from rich.panel import Panel
|
30 | 30 | from rich.style import Style
|
@@ -279,37 +279,99 @@ def _get_timed_out_step_result(self) -> StepResult:
|
279 | 279 | class PytestStep(Step, ABC):
|
280 | 280 | """An abstract class to run pytest tests and evaluate success or failure according to pytest logs."""
|
281 | 281 |
|
| 282 | + PYTEST_INI_FILE_NAME = "pytest.ini" |
| 283 | + PYPROJECT_FILE_NAME = "pyproject.toml" |
| 284 | + extra_dependencies_names = ("dev", "tests") |
282 | 285 | skipped_exit_code = 5
|
283 | 286 |
|
284 |
| - async def _run_tests_in_directory(self, connector_under_test: Container, test_directory: str) -> StepResult: |
285 |
| - """Run the pytest tests in the test_directory that was passed. |
| 287 | + @property |
| 288 | + @abstractmethod |
| 289 | + def test_directory_name(self) -> str: |
| 290 | + raise NotImplementedError("test_directory_name must be implemented in the child class.") |
286 | 291 |
|
287 |
| - A StepStatus.SKIPPED is returned if no tests were discovered. |
| 292 | + async def _run(self, connector_under_test: Container) -> StepResult: |
| 293 | + """Run all pytest tests declared in the test directory of the connector code. |
288 | 294 |
|
289 | 295 | Args:
|
290 | 296 | connector_under_test (Container): The connector under test container.
|
291 |
| - test_directory (str): The directory in which the python test modules are declared |
292 | 297 |
|
293 | 298 | Returns:
|
294 |
| - Tuple[StepStatus, Optional[str], Optional[str]]: Tuple of StepStatus, stderr and stdout. |
| 299 | + StepResult: Failure or success of the unit tests with stdout and stdout. |
295 | 300 | """
|
296 |
| - test_config = "pytest.ini" if await check_path_in_workdir(connector_under_test, "pytest.ini") else "/" + PYPROJECT_TOML_FILE_PATH |
297 |
| - if await check_path_in_workdir(connector_under_test, test_directory): |
298 |
| - tester = connector_under_test.with_exec( |
299 |
| - [ |
300 |
| - "python", |
301 |
| - "-m", |
302 |
| - "pytest", |
303 |
| - "-s", |
304 |
| - test_directory, |
305 |
| - "-c", |
306 |
| - test_config, |
307 |
| - ] |
308 |
| - ) |
309 |
| - return await self.get_step_result(tester) |
| 301 | + if not await self.check_if_tests_are_available(self.test_directory_name): |
| 302 | + return self.skip(f"No {self.test_directory_name} directory found in the connector.") |
| 303 | + |
| 304 | + connector_under_test = connector_under_test.with_(await self.testing_environment(self.extra_dependencies_names)) |
| 305 | + |
| 306 | + return await self.get_step_result(connector_under_test) |
| 307 | + |
| 308 | + async def check_if_tests_are_available(self, test_directory_name: str) -> bool: |
| 309 | + """Check if the tests are available in the connector directory. |
| 310 | +
|
| 311 | + Returns: |
| 312 | + bool: True if the tests are available. |
| 313 | + """ |
| 314 | + connector_dir = await self.context.get_connector_dir() |
| 315 | + connector_dir_entries = await connector_dir.entries() |
| 316 | + return test_directory_name in connector_dir_entries |
| 317 | + |
| 318 | + async def testing_environment(self, extra_dependencies_names: Iterable[str]) -> Callable: |
| 319 | + """Install all extra dependencies of a connector. |
| 320 | +
|
| 321 | + Args: |
| 322 | + extra_dependencies_names (Iterable[str]): Extra dependencies to install. |
310 | 323 |
|
| 324 | + Returns: |
| 325 | + Callable: The decorator to use with the with_ method of a container. |
| 326 | + """ |
| 327 | + secret_mounting_function = await environments.mounted_connector_secrets(self.context, "secrets") |
| 328 | + connector_dir = await self.context.get_connector_dir() |
| 329 | + connector_dir_entries = await connector_dir.entries() |
| 330 | + |
| 331 | + if self.PYTEST_INI_FILE_NAME in connector_dir_entries: |
| 332 | + config_file_name = self.PYTEST_INI_FILE_NAME |
| 333 | + test_config = await self.context.get_connector_dir(include=[self.PYTEST_INI_FILE_NAME]).file(self.PYTEST_INI_FILE_NAME) |
| 334 | + self.logger.info(f"Found {self.PYTEST_INI_FILE_NAME}, using it for testing.") |
| 335 | + elif self.PYPROJECT_FILE_NAME in connector_dir_entries: |
| 336 | + config_file_name = self.PYPROJECT_FILE_NAME |
| 337 | + test_config = await self.context.get_connector_dir(include=[self.PYTEST_INI_FILE_NAME]).file(self.PYTEST_INI_FILE_NAME) |
| 338 | + self.logger.info(f"Found {PYPROJECT_TOML_FILE_PATH} at connector level, using it for testing.") |
311 | 339 | else:
|
312 |
| - return StepResult(self, StepStatus.SKIPPED) |
| 340 | + config_file_name = f"global_{self.PYPROJECT_FILE_NAME}" |
| 341 | + test_config = await self.context.get_repo_dir(include=[self.PYPROJECT_FILE_NAME]).file(self.PYPROJECT_FILE_NAME) |
| 342 | + self.logger.info(f"Found {PYPROJECT_TOML_FILE_PATH} at repo level, using it for testing.") |
| 343 | + |
| 344 | + def prepare_for_testing(built_connector_container: Container) -> Container: |
| 345 | + return ( |
| 346 | + built_connector_container |
| 347 | + # Reset the entrypoint |
| 348 | + .with_entrypoint([]) |
| 349 | + # Mount the connector directory in /test_environment |
| 350 | + # For build optimization the full directory is not mounted by default |
| 351 | + # We need the setup.py/pyproject.toml and the tests code to be available |
| 352 | + # Install the extra dependencies |
| 353 | + .with_mounted_directory("/test_environment", connector_dir) |
| 354 | + # Jump in the /test_environment directory |
| 355 | + .with_workdir("/test_environment").with_mounted_file(config_file_name, test_config) |
| 356 | + # Mount the secrets |
| 357 | + .with_(secret_mounting_function) |
| 358 | + # Install the extra dependencies |
| 359 | + .with_exec(["pip", "install", f".[{','.join(extra_dependencies_names)}]"], skip_entrypoint=True) |
| 360 | + # Execute pytest on the test directory |
| 361 | + .with_exec( |
| 362 | + [ |
| 363 | + "python", |
| 364 | + "-m", |
| 365 | + "pytest", |
| 366 | + "-s", |
| 367 | + self.test_directory_name, |
| 368 | + "-c", |
| 369 | + config_file_name, |
| 370 | + ] |
| 371 | + ) |
| 372 | + ) |
| 373 | + |
| 374 | + return prepare_for_testing |
313 | 375 |
|
314 | 376 |
|
315 | 377 | class NoOpStep(Step):
|
|
0 commit comments