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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to
## [unreleased]

### Added
- build: Provide `expand` unittest parameterization decorator in
`rfl.build.testing.params` as alternative to parameterized library (#75).
- settings: Support optional section-level documentation via `_doc` in settings
definition (#80).

Expand Down
89 changes: 89 additions & 0 deletions src/build/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,92 @@
# RFL: build package

Utilities to help backport builds of Python projects.

## `rfl.build.testing` subpackage

The `rfl.build.testing` subpackage provides reusable helpers for writing unit tests in
downstream projects. It is installable library code shipped with `RFL.build`.

This is distinct from `rfl.tests` under `src/build/rfl/tests/`, which contains unit
tests for the build package itself.

## Unittest parameterization (`rfl.build.testing.params`)

The `params` module provides the `expand` decorator to run the same test logic with
multiple argument sets, as a maintained alternative to the unmaintained third-party
[`parameterized`](https://pypi.org/project/parameterized/) library.

Import:

```python
from rfl.build.testing.params import expand
```

Apply `expand` on a test method with a case list. Each case is passed as positional
arguments to the method (a scalar is wrapped in a one-tuple):

```python
import unittest

from rfl.build.testing.params import expand


class TestValues(unittest.TestCase):
@expand([1, 2, 3])
def test_is_positive(self, value):
self.assertGreater(value, 0)
```

`cases` may be an iterable or a callable returning an iterable. At decoration time,
`expand` injects one test method per case into the enclosing namespace. The template
method is not collected by unittest (`__test__` is set to `False` and the binding is
not kept).

Generated names follow `{method}_{index:03d}_{slug}`, where `slug` is derived from all
case values (for example `test_is_positive_001_1` and `test_is_positive_002_2`).

Options:

- `name_func(func, index, case)` — return the generated method name. `index` is the
1-based case index.
- `skip_on_empty=True` — when `cases` is empty, mark the template as non-runnable
instead of raising `ValueError`.

Example with multiple argument tuples:

```python
class TestApi(unittest.TestCase):
@expand([("1.0", "a"), ("2.0", "b")])
def test_response(self, version, variant):
...
```

The result of `expand(cases)` is itself a decorator and may be assigned to a name to
reuse the same case list on several test methods (similar to `parameterized.expand`):

```python
http_verbs = expand(["get", "post"])


class TestHttpMethods(unittest.TestCase):
@http_verbs
def test_endpoint(self, verb):
response = self.client.open("/resource", method=verb)
self.assertEqual(response.status_code, 200)
```

A callable case source works the same way when building a shared decorator:

```python
def version_combinations():
return [("1.0", "a"), ("2.0", "b")]


all_versions = expand(version_combinations)


class TestApi(unittest.TestCase):
@all_versions
def test_response(self, version, variant):
...
```
7 changes: 6 additions & 1 deletion src/build/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ rfl-install-setup-generator = "rfl.build.installsetup:main"
rfl-project-version = "rfl.build.projectversion:main"

[tool.setuptools.packages.find]
include = ["rfl.build*"]
# Packages are listed explicitly because automatic discovery does not apply here.
# The rfl/ tree is a namespace (no rfl/__init__.py), so the setup.py wrapper used on
# older distributions (Python 3.6 / el8) disables autofind and turns "rfl.build*" into
# "rfl.build" only, without subpackages such as rfl.build.testing. Modern pip with
# PEP 517 would accept "rfl.build*", but the explicit list keeps both toolchains aligned.
include = ["rfl.build", "rfl.build.testing"]

[tool.setuptools.package-data]
"rfl.build.scripts" = ["*"]
Empty file.
94 changes: 94 additions & 0 deletions src/build/rfl/build/testing/params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Copyright (c) 2026 Rackslab
#
# This file is part of RFL.
#
# SPDX-License-Identifier: LGPL-3.0-or-later

import functools
import inspect
import re


def _normalize_case(case):
if isinstance(case, tuple):
return case
return (case,)


def _slugify_case(case):
text = "_".join(str(item) for item in _normalize_case(case))
slug = re.sub(r"[^0-9a-zA-Z]+", "_", text).strip("_").lower()
return slug or "case"


def _expanded_test_name(name, index, case):
slug = _slugify_case(case)
return "{name}_{index:03d}_{slug}".format(name=name, index=index, slug=slug)


def _make_expanded_test(func, args):
@functools.wraps(func)
def expanded(self):
return func(self, *args)

return expanded


def expand(cases, name_func=None, skip_on_empty=False):
"""Build a decorator that turns one unittest method into several test methods.

This is an alternative to the third-party parameterized library. Apply the returned
decorator on a unittest.TestCase method, or assign it to a name and reuse it on
multiple methods (for example http_verbs = expand(["get", "post"])).

At class definition time, each entry in cases becomes a separate test method
injected into the enclosing namespace. The template method is left in place but
marked as non-runnable (__test__ = False), so unittest does not collect it.
Expanded methods call the template with fixed positional arguments: a scalar case
is passed as a single argument, a tuple is unpacked as positional arguments.

Default names follow {method}_{index:03d}_{slug}, where slug is built from all
case values (for example test_endpoint_001_get).

cases is an iterable of parameter values, or a callable returning such an
iterable. The callable form is evaluated once when the decorator is applied,
which is useful when the list depends on runtime data (assets on disk, versions,
and so on).

name_func, when provided, is called as name_func(func, index, case) and must
return the generated method name. index is the 1-based index of the case. When
omitted, the default naming scheme described above is used.

skip_on_empty, when true and cases is empty, marks the template method as
non-runnable instead of raising ValueError. When false (the default), an empty
cases iterable raises ValueError with a short hint about skip_on_empty.
"""

def decorator(func):
if callable(cases):
parameters = list(cases())
else:
parameters = list(cases)

if not parameters:
if skip_on_empty:
func.__test__ = False
return
raise ValueError(
"Parameters iterable is empty (hint: use expand([], "
"skip_on_empty=True) to skip this test when the input is empty)"
)

frame_locals = inspect.currentframe().f_back.f_locals

for index, case in enumerate(parameters, start=1):
args = _normalize_case(case)
if name_func is not None:
expanded_name = name_func(func, index, case)
else:
expanded_name = _expanded_test_name(func.__name__, index, case)
frame_locals[expanded_name] = _make_expanded_test(func, args)

func.__test__ = False

return decorator
142 changes: 142 additions & 0 deletions src/build/rfl/tests/test_params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Copyright (c) 2026 Rackslab
#
# This file is part of RFL.
#
# SPDX-License-Identifier: LGPL-3.0-or-later

import unittest

from rfl.build.testing.params import expand


class TestExpand(unittest.TestCase):
def test_expand_scalar_cases(self):
recorded = []

class ExampleTest(unittest.TestCase):
@expand(["alpha", "beta"])
def test_value(self, value):
recorded.append(value)

suite = unittest.TestLoader().loadTestsFromTestCase(ExampleTest)
self.assertEqual(suite.countTestCases(), 2)
suite.run(unittest.TestResult())
self.assertEqual(recorded, ["alpha", "beta"])
self.assertIsNone(ExampleTest.__dict__.get("test_value"))

def test_expand_tuple_cases(self):
recorded = []

class ExampleTest(unittest.TestCase):
@expand([("1.0", "a"), ("2.0", "b")])
def test_versions(self, version, variant):
recorded.append((version, variant))

suite = unittest.TestLoader().loadTestsFromTestCase(ExampleTest)
self.assertEqual(suite.countTestCases(), 2)
suite.run(unittest.TestResult())
self.assertEqual(recorded, [("1.0", "a"), ("2.0", "b")])

def test_expand_http_verbs(self):
recorded = []

class ExampleTest(unittest.TestCase):
@expand(["get", "post"])
def test_http_verb(self, verb):
recorded.append(verb)

suite = unittest.TestLoader().loadTestsFromTestCase(ExampleTest)
self.assertEqual(suite.countTestCases(), 2)
suite.run(unittest.TestResult())
self.assertEqual(recorded, ["get", "post"])
self.assertIn("test_http_verb_001_get", ExampleTest.__dict__)
self.assertIn("test_http_verb_002_post", ExampleTest.__dict__)

def test_expand_tuple_naming(self):
class ExampleTest(unittest.TestCase):
@expand([(1, "a"), (2, "b")])
def test_tuple_args(self, number, label):
pass

self.assertIn("test_tuple_args_001_1_a", ExampleTest.__dict__)
self.assertIn("test_tuple_args_002_2_b", ExampleTest.__dict__)

def test_expand_callable_cases(self):
recorded = []

def cases():
return ["x", "y"]

class ExampleTest(unittest.TestCase):
@expand(cases)
def test_value(self, value):
recorded.append(value)

suite = unittest.TestLoader().loadTestsFromTestCase(ExampleTest)
self.assertEqual(suite.countTestCases(), 2)
suite.run(unittest.TestResult())
self.assertEqual(recorded, ["x", "y"])

def test_expand_shared_decorator(self):
recorded = []
http_verbs = expand(["get", "post"])

class ExampleTest(unittest.TestCase):
@http_verbs
def test_first(self, verb):
recorded.append(("first", verb))

@http_verbs
def test_second(self, verb):
recorded.append(("second", verb))

suite = unittest.TestLoader().loadTestsFromTestCase(ExampleTest)
self.assertEqual(suite.countTestCases(), 4)
suite.run(unittest.TestResult())
self.assertEqual(
recorded,
[
("first", "get"),
("first", "post"),
("second", "get"),
("second", "post"),
],
)

def test_expand_empty_skip_on_empty(self):
class ExampleTest(unittest.TestCase):
@expand([], skip_on_empty=True)
def test_value(self, value):
raise AssertionError("should not run")

suite = unittest.TestLoader().loadTestsFromTestCase(ExampleTest)
self.assertEqual(suite.countTestCases(), 0)

def test_expand_empty_raises(self):
with self.assertRaises(ValueError):

class ExampleTest(unittest.TestCase):
@expand([])
def test_value(self, value):
pass

def test_expand_default_naming(self):
class ExampleTest(unittest.TestCase):
@expand(["alpha"])
def test_value(self, value):
pass

self.assertIn("test_value_001_alpha", ExampleTest.__dict__)

def test_expand_custom_name_func(self):
class ExampleTest(unittest.TestCase):
@expand(
["alpha"],
name_func=lambda func, index, case: "{}_custom_{}".format(
func.__name__, case
),
)
def test_value(self, value):
pass

self.assertIn("test_value_custom_alpha", ExampleTest.__dict__)
Loading