Metadata-Version: 2.4
Name: yaml-test-params
Version: 0.4.2
Summary: Generate pytest and unittest parameters from YAML configuration files.
Project-URL: Homepage, https://github.com/fomenko-ai/yaml-test-params
Project-URL: Repository, https://github.com/fomenko-ai/yaml-test-params
Project-URL: Issues, https://github.com/fomenko-ai/yaml-test-params/issues
Project-URL: Changelog, https://github.com/fomenko-ai/yaml-test-params/blob/master/CHANGELOG.md
Author-email: Aleksei Fomenko <fomenko_ai@proton.me>
License-Expression: MIT
License-File: LICENSE.txt
Keywords: parametrize,pydantic,pytest,testing,unittest,yaml
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Testing
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: pydantic>=2.0
Requires-Dist: pyyaml>=6.0.2
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == 'dev'
Provides-Extra: pytest
Requires-Dist: pytest>=7.0.0; extra == 'pytest'
Description-Content-Type: text/markdown

# yaml-test-params

<p align="center">
  <img src="https://raw.githubusercontent.com/fomenko-ai/yaml-test-params/master/logo.png" alt="yaml-test-params logo" width="360">
</p>

[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/yaml-test-params.svg)](https://pypi.org/project/yaml-test-params/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)

A Python library for dynamic test parameter generation from YAML configuration files. This library enables flexible, data-driven test scenarios by combining Pydantic models with pytest's parametrize functionality or Python's built-in unittest library.

## Features

- **Configuration-driven tests**: Define test parameters in YAML files instead of hardcoding them
- **Pydantic validation**: Type-safe configuration models with automatic validation
- **Automatic test expansion**: Range configurations are automatically expanded into individual test cases
- **Seamless pytest integration**: Works with pytest's native parametrize mechanism
- **unittest support**: Generated parameter sets can be iterated in standard `unittest.TestCase` tests
- **Flexible parameter types**: Support for simple values, lists, and ranges
- **Custom YAML loading**: Override the default `yaml.safe_load` behavior for tags, preprocessing, includes, or environment variables
- **Custom test case data**: `test_cases` can include any YAML data types accepted by your Pydantic models

## Installation

```bash
uv add yaml-test-params
```

For pytest integration, install the pytest extra:

```bash
uv add "yaml-test-params[pytest]"
```

**Dependencies:**

- Python >= 3.9
- pydantic >= 2.0
- pyyaml >= 6.0.2
- pytest >= 7.0.0 (optional, required for pytest integration)

For local development and examples, install the development extra:

```bash
uv sync --extra dev
```

### AI Agent Skill

`yaml-test-params` includes the `add-yaml-parametrized-tests` skill for
creating new YAML-parametrized tests and converting existing tests to use
`yaml-test-params`.

Install the library, then expose skills bundled with the project's Python
dependencies to supported coding agents:

```bash
uvx library-skills
```

The installer discovers the skill from the installed `yaml-test-params`
package and links it into the project's `.agents/skills/` directory. Review
the skill before enabling it, as you would any instructions used by an AI
agent.

## How It Works

The project supports dynamic parameter generation for tests from configuration files, enabling flexible test scenarios.

### Workflow

1. **Base Pydantic models** define the structure of test cases and the YAML configuration file structure.
2. **YAML configuration** defines test parameters and scenarios.
3. **Test runner integration** uses the generated arguments either through the bundled pytest plugin or directly inside `unittest.TestCase`.

## Quick Start

### Step 1: Define Your Models

Create Pydantic models that represent your test case structure:

```python
from yaml_test_params.models import (
    BaseTestCase,
    BaseTestConfig,
    BaseTestConfigCollection,
    ParametrizeInteger,
    ParametrizeString,
)


class ExampleTestCase(BaseTestCase):
    test_name: str
    integer: ParametrizeInteger
    string: ParametrizeString

    @property
    def arg_id(self) -> str:
        return self.test_name


class ExampleTestConfig(BaseTestConfig):
    test_cases: list[ExampleTestCase]


class ExampleTestConfigCollection(BaseTestConfigCollection):
    collection: list[ExampleTestConfig]
```

### Step 2: Create YAML Configuration

Define your test parameters in a YAML file:

```yaml
collection:
  - name: examples
    test_cases:
      - test_name: int_1,2,3__str_a
        integer:
          values: [1, 2, 3]
        string: a

      - test_name: int_42__str_a,b,c
        integer: 42
        string:
          values: [a, b, c]

      - test_name: int_1_10_1__str_a
        integer:
          from: 1
          to: 10
          step: 1
        string: a

      - test_name: int_1_10_2__str_a,b,c
        integer:
          from: 1
          to: 10
          step: 2
        string:
          values: [a, b, c]
```

### Step 3: Use Generated Parameters in Tests

You can use the generated parameters with either pytest or unittest.

#### Option A: Use the pytest Plugin

Create a reusable configuration source and decorate only the methods that need
YAML parametrization:

```python
from yaml_test_params.pytest import YamlConfigSource, yaml_parametrize
from ..models import ExampleTestConfigCollection


EXAMPLE_CONFIGS = YamlConfigSource(
    path="examples/collection.yaml",
    model=ExampleTestConfigCollection,
)
```

The plugin is discovered automatically when both the package and pytest are
installed. The pytest extra provides the required pytest dependency.
Undecorated methods continue to run as ordinary pytest tests:

```python
class TestParametrizeExamples:
    """Test class demonstrating pytest parametrize with integer and string variables."""

    @yaml_parametrize(EXAMPLE_CONFIGS, "examples")
    def test_values(self, test_name: str, integer: int, string: str):
        """Test that integer and string parameters are correctly passed."""
        print(f"\n==============\n")
        print(f"Test name: {test_name}")
        print(f"integer: {integer}\nstring: {string}")

    def test_value(self):
        """This method is not parametrized from YAML."""
        assert True
```

Run the pytest example:

```bash
uv run pytest -s examples/pytest_tests
```

If pytest plugin auto-loading is disabled, enable the plugin explicitly:

```python
# conftest.py
pytest_plugins = ["yaml_test_params.pytest_plugin"]
```

For a project that already has its own `pytest_generate_tests` hook, call the
public integration function instead:

```python
from yaml_test_params.pytest import generate_yaml_tests


def pytest_generate_tests(metafunc):
    generate_yaml_tests(metafunc)

    # Additional project-specific parametrization can follow.
```

Automatic plugin loading, explicit `pytest_plugins`, and a manual
`generate_yaml_tests()` call are alternative integration modes. Normally only
one is needed; repeated processing of the same pytest metafunction is ignored.

#### Option B: Use unittest

Load the generated arguments once and iterate over them in a `unittest.TestCase`:

```python
import unittest

from yaml_test_params.args_loader import load_parametrize_args
from ..models import ExampleTestConfigCollection


parametrize_args = load_parametrize_args(
    path_to_configs="examples/collection.yaml",
    config_collection_model=ExampleTestConfigCollection,
    collection_name="examples",
)


class TestParametrizeExamples(unittest.TestCase):
    """Test class demonstrating unittest with generated YAML parameters."""

    cases = parametrize_args.argvalues

    def test_values(self):
        """Test that integer and string parameters are correctly passed."""
        for test_name, integer, string in self.cases:
            with self.subTest(test_name=test_name, integer=integer, string=string):
                print(f"\n==============\n")
                print(f"Test name: {test_name}")
                print(f"integer: {integer}\nstring: {string}")
```

Run the unittest example:

```bash
uv run python -m unittest examples.unittest_tests.test_examples
```

See the full examples in [`examples/pytest_tests`](examples/pytest_tests/) and [`examples/unittest_tests`](examples/unittest_tests/).

## Configuration Types

The library supports three types of parameter configurations:

`test_cases` may also contain any additional fields and data types that can be
represented in YAML and validated by your Pydantic models, such as booleans,
lists, dictionaries, nested models, dates, or enums. These fields are passed to
generated test arguments according to the model definition.

The built-in parametrization config models expand `int`, `str`, and `float`
values through `ParametrizeInteger`, `ParametrizeString`, and
`ParametrizeFloat`.

```python
class ExampleTestCase(BaseTestCase):
    integer: ParametrizeInteger
    string: ParametrizeString
    floating_point: ParametrizeFloat
```

```yaml
test_cases:
  - test_name: int_1_10_2__str_a,b,c__float_0.1_0.3_0.1
    integer:
        from: 1
        to: 10
        step: 2
    string:
        values: [a, b, c]
    floating_point:
        from: 0.1
        to: 0.3
        step: 0.1
```

### Simple Value

A single value for a parameter:

```yaml
integer: 42
string: "hello"
floating_point: 0.25
```

### List of Values

Multiple discrete values:

```yaml
integer:
  values: [1, 2, 3]
string:
  values: [a, b, c]
floating_point:
  values: [0.1, 0.25, 0.5]
```

### Range

A range of values with start, end, and step:

```yaml
integer:
  from: 1
  to: 10
  step: 2
```

This generates values: `[1, 3, 5, 7, 9]`.

`step` must be non-zero and point from `from` toward `to`: positive for an
ascending range and negative for a descending range.

Descending ranges are also supported:

```yaml
integer:
  from: 5
  to: 1
  step: -2
```

This generates values: `[5, 3, 1]`.

Floating-point ranges use `FloatRangeConfig`:

```yaml
floating_point:
  from: 0.1
  to: 0.3
  step: 0.1
```

This generates `[0.1, 0.2, 0.3]`. The range is calculated with `Decimal`
arithmetic to avoid accumulating binary floating-point errors, then its values
are passed to tests as `float`. As with integer ranges, `step` must be non-zero
and its sign must match the range direction.

## Available Models

### ValueConfig

Configuration for parameters with a simple value:

```python
class ValueConfig(BaseModel, Generic[T]):
    value: T
```

### ListConfig

Configuration for parameters with a list of values:

```python
class ListConfig(BaseModel, Generic[T]):
    values: list[T]
```

### IntegerRangeConfig

Configuration for parameters with an integer range:

```python
class IntegerRangeConfig(BaseModel):
    from_: int
    to: int
    step: int
```

`RangeConfig` remains available as a backwards-compatible alias for
`IntegerRangeConfig`.

### FloatRangeConfig

Configuration for a floating-point range calculated with `Decimal`:

```python
class FloatRangeConfig(BaseModel):
    from_: Decimal
    to: Decimal
    step: Decimal
```

### Type Aliases

```python
ParametrizeIntegerConfigModels = IntegerRangeConfig | ListConfig[int] | ValueConfig[int]
ParametrizeStringConfigModels = ListConfig[str] | ValueConfig[str]
ParametrizeFloatConfigModels = FloatRangeConfig | ListConfig[float] | ValueConfig[float]
ParametrizeInteger = int | ParametrizeIntegerConfigModels
ParametrizeString = str | ParametrizeStringConfigModels
ParametrizeFloat = float | ParametrizeFloatConfigModels
```

### Base Classes

```python
class BaseTestCase(BaseModel, ABC):
    test_name: str

class BaseTestConfig(BaseModel):
    name: str
    test_cases: list[BaseTestCase]

class BaseTestConfigCollection(BaseModel):
    collection: list[BaseTestConfig]
```

## API Reference

### `load_parametrize_args()`

Loads and parses a YAML configuration file and returns parametrize arguments.

```python
def load_parametrize_args(
    path_to_configs: Union[pathlib.Path, str],
    config_collection_model: Type[TestConfigCollection],
    collection_name: str,
    *,
    yaml_loader: YamlLoader = yaml.safe_load,
) -> ParametrizeArgs:
```

**Parameters:**

| Parameter | Type | Description |
|-----------|------|-------------|
| `path_to_configs` | `pathlib.Path \| str` | Path to the YAML configuration file |
| `config_collection_model` | `Type[TestConfigCollection]` | Pydantic model class for parsing the configuration |
| `collection_name` | `str` | Name of the test collection to use from the configuration |
| `yaml_loader` | `Callable[[TextIO], Any]` | Optional custom YAML loader. Defaults to `yaml.safe_load` |

**Returns:** `ParametrizeArgs` object containing parametrize arguments

**Raises:** `ValueError` if no configuration is found for the given collection name

### Custom YAML Loader

Use `yaml_loader` when you need custom YAML parsing, preprocessing, tags,
includes, or environment variable substitution before Pydantic validation.
The same loader can be used directly with `load_parametrize_args()` or through
`YamlConfigSource` and the pytest plugin.

```python
from typing import TextIO

import yaml


class CustomSafeLoader(yaml.SafeLoader):
    pass


def construct_times_two(loader, node):
    return int(loader.construct_scalar(node)) * 2


CustomSafeLoader.add_constructor("!times_two", construct_times_two)


def custom_yaml_loader(f: TextIO) -> dict:
    return yaml.load(f, Loader=CustomSafeLoader)
```

Use it when loading arguments directly:

```python
from yaml_test_params.args_loader import load_parametrize_args
from ..models import ExampleTestConfigCollection


parametrize_args = load_parametrize_args(
    path_to_configs="examples/collection.yaml",
    config_collection_model=ExampleTestConfigCollection,
    collection_name="examples",
    yaml_loader=custom_yaml_loader,
)
```

Or attach it to a reusable pytest configuration source:

```python
from yaml_test_params.pytest import YamlConfigSource
from ..models import ExampleTestConfigCollection


EXAMPLE_CONFIGS = YamlConfigSource(
    path="examples/collection.yaml",
    model=ExampleTestConfigCollection,
    yaml_loader=custom_yaml_loader,
)
```

### `ParametrizeArgs`

Dataclass holding generated test parameters for pytest and unittest integrations:

```python
@dataclass
class ParametrizeArgs:
    argnames: str | None = None
    argvalues: list[tuple] = field(default_factory=list)
    ids: list[str] = field(default_factory=list)
```

**Methods:**

| Method | Description |
|--------|-------------|
| `init_arg_names(model_cls)` | Initialize argument names from a Pydantic model |
| `add_params(arg_id, arg_values)` | Add a parameterized test case |
| `to_dict()` | Convert to dictionary for `metafunc.parametrize()` |
| `keys` | Property returning the tuple of argument keys |
| `keys_set` | Property returning the set of argument keys |

## Exported Symbols

```python
__all__ = [
    "BaseTestCase",
    "BaseTestConfig",
    "BaseTestConfigCollection",
    "FloatRangeConfig",
    "IntegerRangeConfig",
    "ListConfig",
    "ParametrizeArgs",
    "ParametrizeFloat",
    "ParametrizeFloatConfigModels",
    "ParametrizeInteger",
    "ParametrizeIntegerConfigModels",
    "ParametrizeString",
    "ParametrizeStringConfigModels",
    "RangeConfig",
    "ValueConfig",
    "load_parametrize_args",
]
```

## Python Compatibility Tests

The project tests the latest compatible dependencies on Python 3.9 through
3.14. It also tests the minimum supported versions of Pydantic, PyYAML, and
pytest on Python 3.9 through 3.11, where binary distributions for those
versions are available.

Run the compatibility matrix through pytest:

```bash
uv run pytest python_compatibility_tests -v
```

Alternatively, run the standalone shell script:

```bash
./python_compatibility_tests/run_python_compatibility.sh
```

Both commands use isolated `uv` environments and leave the project's `.venv`
unchanged. Missing Python versions are downloaded automatically by `uv`.

## Project Structure

```
yaml-test-params/
├── examples/
│   ├── collection.yaml
│   ├── models.py
│   ├── pytest_tests/
│   │   ├── conftest.py
│   │   └── test_examples.py
│   └── unittest_tests/
│       └── test_examples.py
├── tests/
│   ├── test_args_loader.py
│   ├── test_models.py
│   ├── test_parametrize_args.py
│   └── test_pytest_integration.py
├── python_compatibility_tests/
│   ├── run_python_compatibility.sh
│   └── test_python_compatibility.py
├── yaml_test_params/
│   ├── __init__.py
│   ├── args_loader.py          # YAML configuration loader
│   ├── models.py               # Pydantic model definitions
│   ├── parametrize_args.py     # Parametrize arguments dataclass
│   ├── pytest.py               # Public pytest integration API
│   ├── pytest_plugin.py        # Automatically discovered pytest plugin
│   └── py.typed                # PEP 561 typing marker
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE.txt
├── pyproject.toml
├── README.md
└── uv.lock
```

## License

MIT
