Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@ Test framework tools and helpers for performance stack project.
This repository provided utilities to assist with test automation, log handling, and result parsing.
It is designed to be a set of helper libraries for test frameworks or custom test runners.


## Logging

`testing-utils` uses Python's standard logging module. All modules log via a package logger, which you can configure in your application.
By default, the logger uses a `NullHandler`. To see logs, configure logging in your main script:

```python
import logging
logging.basicConfig(level=logging.INFO)
```

or provide logging config in pytest configuration:

```ini
log_cli = true
log_cli_level = DEBUG
log_cli_format = %(asctime)s %(levelname)s %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S
```

You can also attach handlers or change the log level for the `testing_utils` logger specifically:

```python
import logging
logger = logging.getLogger("testing_utils")
logger.setLevel(logging.DEBUG)
```

## Features

- **Test scenarios libraries**: Rust and C++ libraries for implementing test scenarios.
Expand Down
4 changes: 4 additions & 0 deletions testing_utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@
"result_entry",
"scenario",
]
import logging

from . import cap_utils
from .build_tools import BazelTools, BuildTools, CargoTools
from .log_container import LogContainer
from .result_entry import ResultEntry
from .scenario import Scenario, ScenarioResult

logging.getLogger(__name__).addHandler(logging.NullHandler())
56 changes: 50 additions & 6 deletions testing_utils/build_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
__all__ = ["BuildTools", "CargoTools", "BazelTools"]

import json
import logging
from abc import ABC, abstractmethod
from pathlib import Path
from subprocess import PIPE, Popen, TimeoutExpired
from typing import Any

import pytest

logger = logging.getLogger(__package__)
# region common


Expand All @@ -47,6 +49,11 @@ def __init__(self, option_prefix: str = "", command_timeout: float = 10.0, build
build_timeout : float
Build command timeout in seconds.
"""
logger.debug(
f"Initializing BuildTools: option_prefix={option_prefix}, "
f"command_timeout={command_timeout}, "
f"build_timeout={build_timeout}"
)
if option_prefix:
self._target_path_flag = f"--{option_prefix}-target-path"
self._target_name_flag = f"--{option_prefix}-target-name"
Expand All @@ -56,6 +63,17 @@ def __init__(self, option_prefix: str = "", command_timeout: float = 10.0, build
self._command_timeout = command_timeout
self._build_timeout = build_timeout

def _command_str(self, command: list[Any]) -> str:
"""
Create a command string from command parts.

Parameters
----------
command : list[Any]
Command as a list.
"""
return " ".join(str(c) for c in command)

@property
def command_timeout(self) -> float:
"""
Expand Down Expand Up @@ -131,7 +149,7 @@ def select_target_path(self, config: pytest.Config, *, expect_exists: bool) -> P
raise pytest.UsageError(f'Either "{self._target_path_flag}" or "{self._target_name_flag}" must be set')

@abstractmethod
def build(self, target_name: str) -> Path:
def build(self, target_name: str, *build_parameters: str) -> Path:
"""
Run build for selected target.
Returns path to built executable.
Expand All @@ -140,6 +158,8 @@ def build(self, target_name: str) -> Path:
----------
target_name : str
Name of the target to build.
build_parameters : str
Additional parameters to pass to build command.
"""


Expand Down Expand Up @@ -177,6 +197,7 @@ def metadata(self) -> dict[str, Any]:
"""
# Run command.
command = ["cargo", "metadata", "--format-version", "1"]
logger.debug(f"Running Cargo metadata command: `{self._command_str(command)}`")
with Popen(command, stdout=PIPE, text=True) as p:
stdout, _ = p.communicate(timeout=self.command_timeout)
if p.returncode != 0:
Expand Down Expand Up @@ -209,9 +230,10 @@ def find_target_path(self, target_name: str, *, expect_exists: bool = True) -> P
if expect_exists and not target_path.exists():
raise RuntimeError(f"Executable not found: {target_path}")

logger.debug(f"Found target path: {target_path}")
return target_path

def build(self, target_name: str) -> Path:
def build(self, target_name: str, *build_parameters: str) -> Path:
"""
Run build for selected target.
Manifest path is taken from Cargo metadata.
Expand All @@ -221,6 +243,8 @@ def build(self, target_name: str) -> Path:
----------
target_name : str
Name of the target to build.
build_parameters : list[str] | None
Additional parameters to pass to build command.
"""
# Read metadata.
metadata = self.metadata()
Expand All @@ -236,7 +260,8 @@ def build(self, target_name: str) -> Path:
manifest_path = Path(pkg_entry["manifest_path"]).resolve()

# Run build.
command = ["cargo", "build", "--manifest-path", manifest_path]
command = ["cargo", "build", "--manifest-path", manifest_path, *build_parameters]
logger.debug(f"Running Cargo build command: `{self._command_str(command)}`")
with Popen(command, text=True) as p:
_, _ = p.communicate(timeout=self.build_timeout)
if p.returncode != 0:
Expand All @@ -255,7 +280,13 @@ class BazelTools(BuildTools):
Utilities for interacting with Bazel.
"""

def __init__(self, option_prefix: str = "", command_timeout: float = 10.0, build_timeout: float = 180.0) -> None:
def __init__(
self,
option_prefix: str = "",
config: str = "",
command_timeout: float = 10.0,
build_timeout: float = 180.0,
) -> None:
"""
Create Bazel tools instance.

Expand All @@ -265,12 +296,18 @@ def __init__(self, option_prefix: str = "", command_timeout: float = 10.0, build
Prefix for options expected by 'select_target_path'.
- '' will expect '--target-path' and '--target-name'.
- 'cpp' will expect '--cpp-target-path' and '--cpp-target-name'.
config : str
Explicitly define config used by Bazel commands.
E.g., "per-x86_64-linux" adds "--config=per-x86_64-linux".
command_timeout : float
Common command timeout in seconds.
build_timeout : float
"bazel build" timeout in seconds.
"""
super().__init__(option_prefix, command_timeout, build_timeout)
# Store 'config' as command parameter nested in a list.
# This is required to avoid empty parts ('') of commands.
self.config_param = [f"--config={config}"] if config else []

def query(self, query: str = "//...") -> list[str]:
"""
Expand All @@ -284,6 +321,7 @@ def query(self, query: str = "//...") -> list[str]:
"""
# Run command.
command = ["bazel", "query", query]
logger.debug(f"Running Bazel query command: `{self._command_str(command)}`")
with Popen(command, stdout=PIPE, text=True) as p:
stdout, _ = p.communicate(timeout=self.command_timeout)
if p.returncode != 0:
Expand Down Expand Up @@ -320,8 +358,10 @@ def find_target_path(self, target_name: str, *, expect_exists: bool = True) -> P
"cquery",
"--output=starlark",
"--starlark:expr=target.files_to_run.executable.path",
*self.config_param,
target_name,
]
logger.debug(f"Running Bazel cquery command: `{self._command_str(command)}`")
with Popen(command, stdout=PIPE, text=True) as p:
target_str, _ = p.communicate(timeout=self.command_timeout)
target_str = target_str.strip()
Expand All @@ -333,19 +373,23 @@ def find_target_path(self, target_name: str, *, expect_exists: bool = True) -> P
if expect_exists and not target_path.exists():
raise RuntimeError(f"Executable not found: {target_path}")

logger.debug(f"Found target path: {target_path}")
return target_path

def build(self, target_name: str, *options) -> Path:
def build(self, target_name: str, *build_parameters: str) -> Path:
"""
Run build for selected target.

Parameters
----------
target_name : str
Name of the target to build.
build_parameters : str
Additional parameters to pass to build command.
"""
# Run build.
command = ["bazel", "build", target_name, *options]
command = ["bazel", "build", *self.config_param, target_name, *build_parameters]
logger.debug(f"Running Bazel build command: `{self._command_str(command)}`")
with Popen(command, text=True) as p:
_, _ = p.communicate(timeout=self.build_timeout)
if p.returncode != 0:
Expand Down
6 changes: 6 additions & 0 deletions testing_utils/cap_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@

__all__ = ["get_caps", "set_caps"]


import logging
import re
from pathlib import Path
from subprocess import DEVNULL, PIPE, Popen

logger = logging.getLogger(__package__)


def get_caps(executable_path: Path | str) -> dict[str, str]:
"""
Expand Down Expand Up @@ -42,6 +46,7 @@ def get_caps(executable_path: Path | str) -> dict[str, str]:
for name in names.split(","):
result[name] = perms

logger.debug(f"Capabilities for {executable_path}: {result}")
return result


Expand Down Expand Up @@ -70,6 +75,7 @@ def set_caps(executable_path: Path | str, caps: dict[str, str]) -> None:
caps_str,
str(executable_path),
]
logger.debug(f"Setting capabilities: `{' '.join(command)}`")
with Popen(command) as p:
_, _ = p.communicate()
if p.returncode != 0:
Expand Down
6 changes: 6 additions & 0 deletions testing_utils/log_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@

__all__ = ["LogContainer"]

import logging
import re
from itertools import groupby
from operator import attrgetter
from typing import Any

from .result_entry import ResultEntry

logger = logging.getLogger(__package__)


class _NotSet:
"""
Expand Down Expand Up @@ -98,6 +101,7 @@ def _logs_by_field_field_only(self, field: str, *, reverse: bool) -> list[Result

if not reverse:
logs.append(log)
logger.debug(f"Filtered {len(logs)} logs by {'NOT' if reverse else ''}{field=}")
return logs

def _logs_by_field_regex_match(self, field: str, pattern: str, *, reverse: bool) -> list[ResultEntry]:
Expand Down Expand Up @@ -131,6 +135,7 @@ def _logs_by_field_regex_match(self, field: str, pattern: str, *, reverse: bool)
found = regex.search(str(found_value)) is not None
if found ^ reverse:
logs.append(log)
logger.debug(f"Filtered {len(logs)} logs by {field=} with {'reversed' if reverse else ''}{pattern=}")
return logs

def _logs_by_field_exact_match(self, field: str, value: Any, *, reverse: bool) -> list[ResultEntry]:
Expand Down Expand Up @@ -159,6 +164,7 @@ def _logs_by_field_exact_match(self, field: str, value: Any, *, reverse: bool) -
found = isinstance(found_value, type(value)) and found_value == value
if found ^ reverse:
logs.append(log)
logger.debug(f"Filtered {len(logs)} logs by {field=} with {'reversed' if reverse else ''}{value=}")
return logs

def _logs_by_field(
Expand Down
4 changes: 4 additions & 0 deletions testing_utils/net/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,9 @@
"address",
"connection",
]
import logging

from .address import Address, IPAddress
from .connection import create_connection

logging.getLogger(__name__).addHandler(logging.NullHandler())
2 changes: 2 additions & 0 deletions testing_utils/net/address.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
import logging
from dataclasses import dataclass
from ipaddress import IPv4Address, IPv6Address, ip_address
from socket import (
Expand All @@ -20,6 +21,7 @@
from typing import Any

type IPAddress = IPv4Address | IPv6Address
logger = logging.getLogger(__package__)


@dataclass
Expand Down
4 changes: 4 additions & 0 deletions testing_utils/net/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
import logging
from socket import (
SOCK_STREAM,
socket,
)

from .address import Address

logger = logging.getLogger(__package__)


def create_connection(address: Address, timeout: float | None = 3.0) -> socket:
"""
Expand All @@ -32,4 +35,5 @@ def create_connection(address: Address, timeout: float | None = 3.0) -> socket:
s = socket(address.family(), SOCK_STREAM)
s.settimeout(timeout)
s.connect(address.to_raw())
logger.debug(f"Created connection to {address} with {timeout=}s")
return s
3 changes: 3 additions & 0 deletions testing_utils/result_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@

__all__ = ["ResultEntry"]

import logging
import re
from typing import Any

logger = logging.getLogger(__package__)


class ResultEntry:
"""
Expand Down
Loading
Loading