From 64cfab00b1076f6e17e6d6ce8532b5dbe258bfdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Thu, 21 May 2026 10:14:31 +0200 Subject: [PATCH 1/4] feat(build): provide alternative to parameterized fix #75 --- CHANGELOG.md | 2 + src/build/rfl/build/testing/__init__.py | 0 src/build/rfl/build/testing/params.py | 94 +++++++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 src/build/rfl/build/testing/__init__.py create mode 100644 src/build/rfl/build/testing/params.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ba1e3e4..925aadf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/src/build/rfl/build/testing/__init__.py b/src/build/rfl/build/testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/build/rfl/build/testing/params.py b/src/build/rfl/build/testing/params.py new file mode 100644 index 0000000..2aa406d --- /dev/null +++ b/src/build/rfl/build/testing/params.py @@ -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 From 1e1da8a98725bb515c39014621a8308e6fef534f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Thu, 21 May 2026 10:15:15 +0200 Subject: [PATCH 2/4] docs(build): document new build.testing.params API --- src/build/README.md | 89 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/build/README.md b/src/build/README.md index ed82f5c..ab21336 100644 --- a/src/build/README.md +++ b/src/build/README.md @@ -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): + ... +``` From 490d5c14bef7bf94190adb9ffe2a96d0b2d39d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Thu, 21 May 2026 10:15:57 +0200 Subject: [PATCH 3/4] tests(build): cover testing.params module --- src/build/rfl/tests/test_params.py | 142 +++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 src/build/rfl/tests/test_params.py diff --git a/src/build/rfl/tests/test_params.py b/src/build/rfl/tests/test_params.py new file mode 100644 index 0000000..cbed85f --- /dev/null +++ b/src/build/rfl/tests/test_params.py @@ -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__) From a4e8be38e43e9d5fa92957dc5ff1b4950b23d7c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Thu, 21 May 2026 11:06:15 +0200 Subject: [PATCH 4/4] chore(build): find and install testing subpackage --- src/build/pyproject.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/build/pyproject.toml b/src/build/pyproject.toml index 9b5d853..6552192 100644 --- a/src/build/pyproject.toml +++ b/src/build/pyproject.toml @@ -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" = ["*"]