Skip to content

Commit 1956a9a

Browse files
Add a decorator for declarative test environments (#250)
1 parent 9571043 commit 1956a9a

5 files changed

Lines changed: 291 additions & 11 deletions

File tree

RLTest/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from RLTest.env import Env, Defaults
2+
from RLTest.env_spec import env_spec
23
from RLTest.redis_std import StandardEnv
34
from ._version import __version__
45

56
__all__ = [
67
'Defaults',
78
'Env',
8-
'StandardEnv'
9+
'StandardEnv',
10+
'env_spec',
911
]
1012

RLTest/__main__.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -712,14 +712,23 @@ def _runTest(self, test, numberOfAssertionFailed=0, prefix='', before=lambda x=N
712712
except:
713713
test_args = inspect.getfullargspec(test.target).args
714714

715-
if len(test_args) > 0 and not test.is_method:
715+
# Only function-style tests receive ``env`` as a parameter. Class
716+
# methods access env via ``self`` (the class stashes it in
717+
# ``__init__``); declaring ``env`` on a method will surface as a
718+
# natural ``TypeError`` through the failure path below.
719+
env = None
720+
if test_args and not test.is_method:
721+
spec = getattr(test, 'env_spec', None)
716722
try:
717-
# env = Env(testName=test.name)
718-
env = Defaults.env_factory(testName=test.name)
723+
if spec is not None:
724+
env = Defaults.env_factory(testName=test.name, **spec)
725+
else:
726+
env = Defaults.env_factory(testName=test.name)
719727
except Exception as e:
720728
self.handleFailure(testFullName=testFullName, exception=e, prefix=msgPrefix, testname=test.name)
721729
return 0
722730

731+
if env is not None:
723732
fn = lambda: test.target(env)
724733
before_func = lambda: before(env)
725734
after_func = lambda: after(env)
@@ -832,7 +841,17 @@ def run_single_test(self, test, on_timeout_func):
832841

833842
Defaults.curr_test_name = test.name
834843
try:
835-
obj = test.create_instance()
844+
# If the class declared an env_spec, build the env up
845+
# front and pass it to ``__init__``. What the class
846+
# does with it after that is its own business — the
847+
# runner never reads attributes off the instance.
848+
# Test methods access env through ``self``.
849+
spec = getattr(test, 'env_spec', None)
850+
if spec is not None:
851+
env = Defaults.env_factory(testName=test.name, **spec)
852+
obj = test.create_instance(env)
853+
else:
854+
obj = test.create_instance()
836855

837856
except unittest.SkipTest:
838857
self.printSkip(test.name)

RLTest/env_spec.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Declarative environment requirements for RLTest tests.
2+
3+
A test can declare the Env parameters it needs *before* it runs, so the runner
4+
can construct the env on its behalf and inject it as a parameter. Two benefits:
5+
6+
1. Single source of truth: the declared spec is exactly the shape of the env
7+
that gets injected, eliminating drift between a "what env I need" hint and
8+
the in-body ``Env(...)`` call.
9+
2. Future schedulers can read each test's spec at discovery time and route
10+
same-spec tests adjacently to maximize Redis-instance reuse via
11+
``Env.compareEnvs`` (env.py:191).
12+
13+
A spec is declared by applying ``@env_spec(...)`` to a test function or to a
14+
test class. A class-level spec applies to every method of that class;
15+
method-level decoration is not supported (see ``env_spec`` below).
16+
17+
For file-wide defaults, define a local dict and spread it into each
18+
decoration::
19+
20+
BASE = dict(moduleArgs='DEFAULT_DIALECT 2')
21+
22+
@env_spec(**BASE, shardsCount=3)
23+
def test_cluster(env):
24+
...
25+
26+
How env is delivered:
27+
28+
- Function tests receive the constructed env as a parameter (``def
29+
test_x(env):``).
30+
- Class tests receive it once, through ``__init__(self, env)``, and are
31+
responsible for stashing it for their methods to use. By convention that
32+
attribute is ``self.env``, but the runner does not enforce the name — it
33+
hands env to ``__init__`` and then forgets about it. Test methods **never**
34+
receive env as a parameter; they reach it through ``self``.
35+
36+
Example::
37+
38+
@env_spec(shardsCount=3)
39+
def test_cluster(env):
40+
env.expect('FT.SEARCH', 'idx', '*').noError()
41+
42+
@env_spec(moduleArgs='WORKERS 1')
43+
class TestWorkers:
44+
def __init__(self, env):
45+
self.env = env # required: methods access env via ``self``
46+
47+
def test_x(self):
48+
self.env.expect(...)
49+
"""
50+
import inspect
51+
52+
from RLTest.env import Env
53+
54+
_SPEC_KEYS = frozenset(Env.EnvCompareParams)
55+
_ATTR = '_rltest_env_spec'
56+
57+
58+
def _looks_like_class_method(target):
59+
"""Heuristic: is ``target`` a function defined inside a class body?
60+
61+
At decoration time the function isn't bound to the class yet, but Python
62+
has already populated ``__qualname__`` with the enclosing scope. Examples:
63+
64+
f -> top-level function (not a method)
65+
outer.<locals>.g -> nested function (not a method)
66+
C.m -> class method
67+
outer.<locals>.C.m -> class defined inside a function; still a method
68+
69+
The rule: take whatever follows the last ``<locals>.`` (the path *inside*
70+
the innermost enclosing function scope, or the whole qualname if there's
71+
no ``<locals>``). If that trailing segment contains a dot, the target is
72+
qualified by a class name and is therefore a method.
73+
"""
74+
qn = getattr(target, '__qualname__', '')
75+
if not qn:
76+
return False
77+
trailing = qn.rsplit('<locals>.', 1)[-1]
78+
return '.' in trailing
79+
80+
81+
def env_spec(**kwargs):
82+
"""Declare the env requirements of a test function or test class.
83+
84+
Allowed keys are the entries of ``Env.EnvCompareParams``; unknown keys
85+
raise ``ValueError`` at decoration time so typos can't silently disable
86+
spec-driven behaviour.
87+
88+
Applying ``@env_spec`` to a method inside a class is rejected: class tests
89+
share a single env across all their methods (that's the whole point of a
90+
class test). If one method needs a different env, lift it out into a
91+
standalone function or its own class. To declare a class-wide spec,
92+
decorate the class itself.
93+
"""
94+
unknown = set(kwargs) - _SPEC_KEYS
95+
if unknown:
96+
raise ValueError(
97+
"unknown env_spec keys: {}; allowed keys are: {}".format(
98+
sorted(unknown), sorted(_SPEC_KEYS)
99+
)
100+
)
101+
102+
spec = dict(kwargs)
103+
104+
def deco(target):
105+
if inspect.isfunction(target) and _looks_like_class_method(target):
106+
raise TypeError(
107+
"@env_spec is not supported on class methods (got {}). "
108+
"Class tests share one env across all methods; decorate the "
109+
"class itself, or move the test out of the class.".format(
110+
target.__qualname__
111+
)
112+
)
113+
setattr(target, _ATTR, spec)
114+
return target
115+
116+
return deco
117+
118+
119+
def resolve_spec(target):
120+
"""Return the declared env spec for ``target``, or ``None`` if none was
121+
declared via ``@env_spec(...)``.
122+
123+
``target`` is a test function or test class. The ``None`` return is the
124+
sentinel callers use for "no declared spec — fall back to default env
125+
construction."
126+
"""
127+
spec = getattr(target, _ATTR, None)
128+
return dict(spec) if spec is not None else None
129+
130+
131+
def spec_key(spec):
132+
"""Canonical hashable key for spec equivalence.
133+
134+
Two tests with the same ``spec_key`` produce envs that satisfy
135+
``Env.compareEnvs``, so they're eligible to share a Redis instance via
136+
RLTest's opportunistic-reuse path (env.py:262). Future schedulers can use
137+
this as a grouping key.
138+
"""
139+
if spec is None:
140+
return ()
141+
return tuple(sorted(spec.items()))

RLTest/loader.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,22 @@
33
import sys
44
import importlib.util
55
import inspect
6+
from RLTest.env_spec import resolve_spec
67
from RLTest.utils import Colors
78

89

910
class TestFunction(object):
1011
is_class = False
1112

12-
def __init__(self, filename, symbol, modulename):
13+
def __init__(self, filename, symbol, modulename, env_spec=None):
1314
self.filename = filename
1415
self.symbol = symbol
1516
self.modulename = modulename
1617
self.is_method = False
1718
self.name = '{}:{}'.format(self.modulename, symbol)
19+
# Resolved env requirements (dict or None). None means "no declared
20+
# spec — fall back to legacy behaviour".
21+
self.env_spec = env_spec
1822

1923
def initialize(self):
2024
module_spec = importlib.util.spec_from_file_location(self.modulename, self.filename)
@@ -30,10 +34,12 @@ def shortname(self):
3034
class TestMethod(object):
3135
is_class = False
3236

33-
def __init__(self, obj, name):
37+
def __init__(self, obj, name, env_spec=None):
3438
self.target = obj
3539
self.name = name
3640
self.is_method = True
41+
# Methods inherit their class's env_spec; they cannot override it.
42+
self.env_spec = env_spec
3743

3844
def initialize(self):
3945
pass
@@ -44,12 +50,13 @@ def shortname(self):
4450
class TestClass(object):
4551
is_class = True
4652

47-
def __init__(self, filename, symbol, modulename, functions):
53+
def __init__(self, filename, symbol, modulename, functions, env_spec=None):
4854
self.filename = filename
4955
self.symbol = symbol
5056
self.modulename = modulename
5157
self.functions = functions
5258
self.name = '{}:{}'.format(self.modulename, symbol)
59+
self.env_spec = env_spec
5360

5461
def initialize(self):
5562
module_spec = importlib.util.spec_from_file_location(self.modulename, self.filename)
@@ -70,7 +77,8 @@ def get_functions(self, instance):
7077
if not callable(bound):
7178
continue
7279
fns.append(TestMethod(bound,
73-
name='{}:{}.{}'.format(self.modulename, self.clsname, mname)))
80+
name='{}:{}.{}'.format(self.modulename, self.clsname, mname),
81+
env_spec=self.env_spec))
7482
return fns
7583

7684

@@ -129,9 +137,15 @@ def load_files(self, module_dir, module_name, toplevel_filter=None, subfilter=No
129137
if inspect.isclass(obj):
130138
methnames = [mname for mname in dir(obj)
131139
if self.filter_method(mname, subfilter)]
132-
self.tests.append(TestClass(filename, symbol, module_name, methnames))
140+
spec = resolve_spec(obj)
141+
self.tests.append(
142+
TestClass(filename, symbol, module_name, methnames, env_spec=spec)
143+
)
133144
elif inspect.isfunction(obj):
134-
self.tests.append(TestFunction(filename, symbol, module_name))
145+
spec = resolve_spec(obj)
146+
self.tests.append(
147+
TestFunction(filename, symbol, module_name, env_spec=spec)
148+
)
135149
except OSError as e:
136150
print(Colors.Red("Can't access file %s." % filename))
137151
raise e

tests/unit/test_env_spec.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Unit tests for the declarative env_spec mechanism."""
2+
import pytest
3+
4+
from RLTest.env_spec import env_spec, resolve_spec, spec_key, _ATTR
5+
6+
7+
# -- env_spec decorator -------------------------------------------------------
8+
9+
def test_decorator_accepts_allowed_keys():
10+
@env_spec(moduleArgs='FOO 1', shardsCount=3)
11+
def t(env):
12+
pass
13+
14+
assert getattr(t, _ATTR) == {'moduleArgs': 'FOO 1', 'shardsCount': 3}
15+
16+
17+
def test_decorator_rejects_unknown_keys():
18+
with pytest.raises(ValueError, match='unknown env_spec keys'):
19+
@env_spec(badkey=1)
20+
def t(env):
21+
pass
22+
23+
24+
def test_decorator_rejects_class_methods():
25+
with pytest.raises(TypeError, match='not supported on class methods'):
26+
class C:
27+
@env_spec(moduleArgs='X')
28+
def test_x(self):
29+
pass
30+
31+
32+
def test_decorator_allows_nested_functions():
33+
# Inner functions inside a function (not a class) should be fine; they
34+
# appear in the qualname as ``outer.<locals>.inner``.
35+
def outer():
36+
@env_spec(moduleArgs='X')
37+
def inner(env):
38+
pass
39+
return inner
40+
41+
assert getattr(outer(), _ATTR) == {'moduleArgs': 'X'}
42+
43+
44+
def test_decorator_on_class_is_allowed():
45+
# Decorating the class itself (rather than one of its methods) is the
46+
# supported alternative to a class attribute. The spec lands on the class.
47+
@env_spec(moduleArgs='X')
48+
class C:
49+
def __init__(self, env):
50+
self.env = env
51+
52+
assert getattr(C, _ATTR) == {'moduleArgs': 'X'}
53+
54+
55+
# -- resolve_spec -------------------------------------------------------------
56+
57+
def test_resolve_returns_none_when_nothing_declared():
58+
def f(env):
59+
pass
60+
61+
assert resolve_spec(f) is None
62+
63+
64+
def test_resolve_picks_up_function_decoration():
65+
@env_spec(moduleArgs='FROM_FUNC')
66+
def f(env):
67+
pass
68+
69+
assert resolve_spec(f) == {'moduleArgs': 'FROM_FUNC'}
70+
71+
72+
def test_resolve_picks_up_class_decoration():
73+
@env_spec(moduleArgs='FROM_CLASS_DECO')
74+
class C:
75+
pass
76+
77+
assert resolve_spec(C) == {'moduleArgs': 'FROM_CLASS_DECO'}
78+
79+
80+
def test_resolve_ignores_plain_class_attribute():
81+
# ``env_spec = {...}`` as a plain attribute is NOT recognised — only the
82+
# decorator ``@env_spec(...)`` is. This keeps the API surface small.
83+
class C:
84+
env_spec = {'moduleArgs': 'IGNORED'}
85+
86+
assert resolve_spec(C) is None
87+
88+
89+
# -- spec_key -----------------------------------------------------------------
90+
91+
def test_spec_key_is_order_independent():
92+
a = {'moduleArgs': 'X', 'shardsCount': 3}
93+
b = {'shardsCount': 3, 'moduleArgs': 'X'}
94+
assert spec_key(a) == spec_key(b)
95+
96+
97+
def test_spec_key_distinguishes_specs():
98+
a = {'moduleArgs': 'X'}
99+
b = {'moduleArgs': 'Y'}
100+
assert spec_key(a) != spec_key(b)
101+
102+
103+
def test_spec_key_none_is_empty_tuple():
104+
assert spec_key(None) == ()

0 commit comments

Comments
 (0)