From bf6ab05824799792c39ccad081eb91874293ac64 Mon Sep 17 00:00:00 2001 From: LukeMathWalker <20745048+LukeMathWalker@users.noreply.github.com> Date: Mon, 18 May 2026 16:01:25 +0200 Subject: [PATCH] Add a decorator for declarative test environments --- RLTest/__init__.py | 4 +- RLTest/__main__.py | 27 ++++++- RLTest/env_spec.py | 141 ++++++++++++++++++++++++++++++++++++ RLTest/loader.py | 26 +++++-- tests/unit/test_env_spec.py | 104 ++++++++++++++++++++++++++ 5 files changed, 291 insertions(+), 11 deletions(-) create mode 100644 RLTest/env_spec.py create mode 100644 tests/unit/test_env_spec.py diff --git a/RLTest/__init__.py b/RLTest/__init__.py index 3e775fe3..5a95f530 100644 --- a/RLTest/__init__.py +++ b/RLTest/__init__.py @@ -1,10 +1,12 @@ from RLTest.env import Env, Defaults +from RLTest.env_spec import env_spec from RLTest.redis_std import StandardEnv from ._version import __version__ __all__ = [ 'Defaults', 'Env', - 'StandardEnv' + 'StandardEnv', + 'env_spec', ] diff --git a/RLTest/__main__.py b/RLTest/__main__.py index 6414123c..0d5e1754 100644 --- a/RLTest/__main__.py +++ b/RLTest/__main__.py @@ -712,14 +712,23 @@ def _runTest(self, test, numberOfAssertionFailed=0, prefix='', before=lambda x=N except: test_args = inspect.getfullargspec(test.target).args - if len(test_args) > 0 and not test.is_method: + # Only function-style tests receive ``env`` as a parameter. Class + # methods access env via ``self`` (the class stashes it in + # ``__init__``); declaring ``env`` on a method will surface as a + # natural ``TypeError`` through the failure path below. + env = None + if test_args and not test.is_method: + spec = getattr(test, 'env_spec', None) try: - # env = Env(testName=test.name) - env = Defaults.env_factory(testName=test.name) + if spec is not None: + env = Defaults.env_factory(testName=test.name, **spec) + else: + env = Defaults.env_factory(testName=test.name) except Exception as e: self.handleFailure(testFullName=testFullName, exception=e, prefix=msgPrefix, testname=test.name) return 0 + if env is not None: fn = lambda: test.target(env) before_func = lambda: before(env) after_func = lambda: after(env) @@ -832,7 +841,17 @@ def run_single_test(self, test, on_timeout_func): Defaults.curr_test_name = test.name try: - obj = test.create_instance() + # If the class declared an env_spec, build the env up + # front and pass it to ``__init__``. What the class + # does with it after that is its own business — the + # runner never reads attributes off the instance. + # Test methods access env through ``self``. + spec = getattr(test, 'env_spec', None) + if spec is not None: + env = Defaults.env_factory(testName=test.name, **spec) + obj = test.create_instance(env) + else: + obj = test.create_instance() except unittest.SkipTest: self.printSkip(test.name) diff --git a/RLTest/env_spec.py b/RLTest/env_spec.py new file mode 100644 index 00000000..619b05e2 --- /dev/null +++ b/RLTest/env_spec.py @@ -0,0 +1,141 @@ +"""Declarative environment requirements for RLTest tests. + +A test can declare the Env parameters it needs *before* it runs, so the runner +can construct the env on its behalf and inject it as a parameter. Two benefits: + +1. Single source of truth: the declared spec is exactly the shape of the env + that gets injected, eliminating drift between a "what env I need" hint and + the in-body ``Env(...)`` call. +2. Future schedulers can read each test's spec at discovery time and route + same-spec tests adjacently to maximize Redis-instance reuse via + ``Env.compareEnvs`` (env.py:191). + +A spec is declared by applying ``@env_spec(...)`` to a test function or to a +test class. A class-level spec applies to every method of that class; +method-level decoration is not supported (see ``env_spec`` below). + +For file-wide defaults, define a local dict and spread it into each +decoration:: + + BASE = dict(moduleArgs='DEFAULT_DIALECT 2') + + @env_spec(**BASE, shardsCount=3) + def test_cluster(env): + ... + +How env is delivered: + +- Function tests receive the constructed env as a parameter (``def + test_x(env):``). +- Class tests receive it once, through ``__init__(self, env)``, and are + responsible for stashing it for their methods to use. By convention that + attribute is ``self.env``, but the runner does not enforce the name — it + hands env to ``__init__`` and then forgets about it. Test methods **never** + receive env as a parameter; they reach it through ``self``. + +Example:: + + @env_spec(shardsCount=3) + def test_cluster(env): + env.expect('FT.SEARCH', 'idx', '*').noError() + + @env_spec(moduleArgs='WORKERS 1') + class TestWorkers: + def __init__(self, env): + self.env = env # required: methods access env via ``self`` + + def test_x(self): + self.env.expect(...) +""" +import inspect + +from RLTest.env import Env + +_SPEC_KEYS = frozenset(Env.EnvCompareParams) +_ATTR = '_rltest_env_spec' + + +def _looks_like_class_method(target): + """Heuristic: is ``target`` a function defined inside a class body? + + At decoration time the function isn't bound to the class yet, but Python + has already populated ``__qualname__`` with the enclosing scope. Examples: + + f -> top-level function (not a method) + outer..g -> nested function (not a method) + C.m -> class method + outer..C.m -> class defined inside a function; still a method + + The rule: take whatever follows the last ``.`` (the path *inside* + the innermost enclosing function scope, or the whole qualname if there's + no ````). If that trailing segment contains a dot, the target is + qualified by a class name and is therefore a method. + """ + qn = getattr(target, '__qualname__', '') + if not qn: + return False + trailing = qn.rsplit('.', 1)[-1] + return '.' in trailing + + +def env_spec(**kwargs): + """Declare the env requirements of a test function or test class. + + Allowed keys are the entries of ``Env.EnvCompareParams``; unknown keys + raise ``ValueError`` at decoration time so typos can't silently disable + spec-driven behaviour. + + Applying ``@env_spec`` to a method inside a class is rejected: class tests + share a single env across all their methods (that's the whole point of a + class test). If one method needs a different env, lift it out into a + standalone function or its own class. To declare a class-wide spec, + decorate the class itself. + """ + unknown = set(kwargs) - _SPEC_KEYS + if unknown: + raise ValueError( + "unknown env_spec keys: {}; allowed keys are: {}".format( + sorted(unknown), sorted(_SPEC_KEYS) + ) + ) + + spec = dict(kwargs) + + def deco(target): + if inspect.isfunction(target) and _looks_like_class_method(target): + raise TypeError( + "@env_spec is not supported on class methods (got {}). " + "Class tests share one env across all methods; decorate the " + "class itself, or move the test out of the class.".format( + target.__qualname__ + ) + ) + setattr(target, _ATTR, spec) + return target + + return deco + + +def resolve_spec(target): + """Return the declared env spec for ``target``, or ``None`` if none was + declared via ``@env_spec(...)``. + + ``target`` is a test function or test class. The ``None`` return is the + sentinel callers use for "no declared spec — fall back to default env + construction." + """ + spec = getattr(target, _ATTR, None) + return dict(spec) if spec is not None else None + + +def spec_key(spec): + """Canonical hashable key for spec equivalence. + + Two tests with the same ``spec_key`` produce envs that satisfy + ``Env.compareEnvs``, so they're eligible to share a Redis instance via + RLTest's opportunistic-reuse path (env.py:262). Future schedulers can use + this as a grouping key. + """ + if spec is None: + return () + return tuple(sorted(spec.items())) diff --git a/RLTest/loader.py b/RLTest/loader.py index 8d20265b..40995ded 100644 --- a/RLTest/loader.py +++ b/RLTest/loader.py @@ -3,18 +3,22 @@ import sys import importlib.util import inspect +from RLTest.env_spec import resolve_spec from RLTest.utils import Colors class TestFunction(object): is_class = False - def __init__(self, filename, symbol, modulename): + def __init__(self, filename, symbol, modulename, env_spec=None): self.filename = filename self.symbol = symbol self.modulename = modulename self.is_method = False self.name = '{}:{}'.format(self.modulename, symbol) + # Resolved env requirements (dict or None). None means "no declared + # spec — fall back to legacy behaviour". + self.env_spec = env_spec def initialize(self): module_spec = importlib.util.spec_from_file_location(self.modulename, self.filename) @@ -30,10 +34,12 @@ def shortname(self): class TestMethod(object): is_class = False - def __init__(self, obj, name): + def __init__(self, obj, name, env_spec=None): self.target = obj self.name = name self.is_method = True + # Methods inherit their class's env_spec; they cannot override it. + self.env_spec = env_spec def initialize(self): pass @@ -44,12 +50,13 @@ def shortname(self): class TestClass(object): is_class = True - def __init__(self, filename, symbol, modulename, functions): + def __init__(self, filename, symbol, modulename, functions, env_spec=None): self.filename = filename self.symbol = symbol self.modulename = modulename self.functions = functions self.name = '{}:{}'.format(self.modulename, symbol) + self.env_spec = env_spec def initialize(self): module_spec = importlib.util.spec_from_file_location(self.modulename, self.filename) @@ -70,7 +77,8 @@ def get_functions(self, instance): if not callable(bound): continue fns.append(TestMethod(bound, - name='{}:{}.{}'.format(self.modulename, self.clsname, mname))) + name='{}:{}.{}'.format(self.modulename, self.clsname, mname), + env_spec=self.env_spec)) return fns @@ -129,9 +137,15 @@ def load_files(self, module_dir, module_name, toplevel_filter=None, subfilter=No if inspect.isclass(obj): methnames = [mname for mname in dir(obj) if self.filter_method(mname, subfilter)] - self.tests.append(TestClass(filename, symbol, module_name, methnames)) + spec = resolve_spec(obj) + self.tests.append( + TestClass(filename, symbol, module_name, methnames, env_spec=spec) + ) elif inspect.isfunction(obj): - self.tests.append(TestFunction(filename, symbol, module_name)) + spec = resolve_spec(obj) + self.tests.append( + TestFunction(filename, symbol, module_name, env_spec=spec) + ) except OSError as e: print(Colors.Red("Can't access file %s." % filename)) raise e diff --git a/tests/unit/test_env_spec.py b/tests/unit/test_env_spec.py new file mode 100644 index 00000000..308f5741 --- /dev/null +++ b/tests/unit/test_env_spec.py @@ -0,0 +1,104 @@ +"""Unit tests for the declarative env_spec mechanism.""" +import pytest + +from RLTest.env_spec import env_spec, resolve_spec, spec_key, _ATTR + + +# -- env_spec decorator ------------------------------------------------------- + +def test_decorator_accepts_allowed_keys(): + @env_spec(moduleArgs='FOO 1', shardsCount=3) + def t(env): + pass + + assert getattr(t, _ATTR) == {'moduleArgs': 'FOO 1', 'shardsCount': 3} + + +def test_decorator_rejects_unknown_keys(): + with pytest.raises(ValueError, match='unknown env_spec keys'): + @env_spec(badkey=1) + def t(env): + pass + + +def test_decorator_rejects_class_methods(): + with pytest.raises(TypeError, match='not supported on class methods'): + class C: + @env_spec(moduleArgs='X') + def test_x(self): + pass + + +def test_decorator_allows_nested_functions(): + # Inner functions inside a function (not a class) should be fine; they + # appear in the qualname as ``outer..inner``. + def outer(): + @env_spec(moduleArgs='X') + def inner(env): + pass + return inner + + assert getattr(outer(), _ATTR) == {'moduleArgs': 'X'} + + +def test_decorator_on_class_is_allowed(): + # Decorating the class itself (rather than one of its methods) is the + # supported alternative to a class attribute. The spec lands on the class. + @env_spec(moduleArgs='X') + class C: + def __init__(self, env): + self.env = env + + assert getattr(C, _ATTR) == {'moduleArgs': 'X'} + + +# -- resolve_spec ------------------------------------------------------------- + +def test_resolve_returns_none_when_nothing_declared(): + def f(env): + pass + + assert resolve_spec(f) is None + + +def test_resolve_picks_up_function_decoration(): + @env_spec(moduleArgs='FROM_FUNC') + def f(env): + pass + + assert resolve_spec(f) == {'moduleArgs': 'FROM_FUNC'} + + +def test_resolve_picks_up_class_decoration(): + @env_spec(moduleArgs='FROM_CLASS_DECO') + class C: + pass + + assert resolve_spec(C) == {'moduleArgs': 'FROM_CLASS_DECO'} + + +def test_resolve_ignores_plain_class_attribute(): + # ``env_spec = {...}`` as a plain attribute is NOT recognised — only the + # decorator ``@env_spec(...)`` is. This keeps the API surface small. + class C: + env_spec = {'moduleArgs': 'IGNORED'} + + assert resolve_spec(C) is None + + +# -- spec_key ----------------------------------------------------------------- + +def test_spec_key_is_order_independent(): + a = {'moduleArgs': 'X', 'shardsCount': 3} + b = {'shardsCount': 3, 'moduleArgs': 'X'} + assert spec_key(a) == spec_key(b) + + +def test_spec_key_distinguishes_specs(): + a = {'moduleArgs': 'X'} + b = {'moduleArgs': 'Y'} + assert spec_key(a) != spec_key(b) + + +def test_spec_key_none_is_empty_tuple(): + assert spec_key(None) == ()