Skip to content

Commit 54fbda2

Browse files
Add a decorator for declarative test environments
1 parent 9571043 commit 54fbda2

7 files changed

Lines changed: 321 additions & 11 deletions
Binary file not shown.
Binary file not shown.

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: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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. When a class is decorated, any spec applied to the class merges
15+
under specs on its methods — but in practice methods can't be decorated (see
16+
``env_spec``'s docstring), so this collapses to: class spec applies to every
17+
method of that class.
18+
19+
For file-wide defaults, define a local dict and spread it into each
20+
decoration::
21+
22+
BASE = dict(moduleArgs='DEFAULT_DIALECT 2')
23+
24+
@env_spec(**BASE, shardsCount=3)
25+
def test_cluster(env):
26+
...
27+
28+
How env is delivered:
29+
30+
- Function tests receive the constructed env as a parameter (``def
31+
test_x(env):``).
32+
- Class tests receive it once, through ``__init__(self, env)``, and are
33+
responsible for stashing it for their methods to use. By convention that
34+
attribute is ``self.env``, but the runner does not enforce the name — it
35+
hands env to ``__init__`` and then forgets about it. Test methods **never**
36+
receive env as a parameter; they reach it through ``self``.
37+
38+
Example::
39+
40+
@env_spec(shardsCount=3)
41+
def test_cluster(env):
42+
env.expect('FT.SEARCH', 'idx', '*').noError()
43+
44+
@env_spec(moduleArgs='WORKERS 1')
45+
class TestWorkers:
46+
def __init__(self, env):
47+
self.env = env # required: methods access env via ``self``
48+
49+
def test_x(self):
50+
self.env.expect(...)
51+
"""
52+
import inspect
53+
54+
from RLTest.env import Env
55+
56+
_SPEC_KEYS = frozenset(Env.EnvCompareParams)
57+
_ATTR = '_rltest_env_spec'
58+
59+
60+
def _looks_like_class_method(target):
61+
"""Heuristic: is ``target`` a function defined inside a class body?
62+
63+
At decoration time the function isn't bound to the class yet, but Python
64+
has already populated ``__qualname__`` with the enclosing scope. Examples:
65+
66+
f -> top-level function (not a method)
67+
outer.<locals>.g -> nested function (not a method)
68+
C.m -> class method
69+
outer.<locals>.C.m -> class defined inside a function; still a method
70+
71+
The rule: take whatever follows the last ``<locals>.`` (the path *inside*
72+
the innermost enclosing function scope, or the whole qualname if there's
73+
no ``<locals>``). If that trailing segment contains a dot, the target is
74+
qualified by a class name and is therefore a method.
75+
"""
76+
qn = getattr(target, '__qualname__', '')
77+
if not qn:
78+
return False
79+
trailing = qn.rsplit('<locals>.', 1)[-1]
80+
return '.' in trailing
81+
82+
83+
def env_spec(**kwargs):
84+
"""Declare the env requirements of a test function or test class.
85+
86+
Allowed keys are the entries of ``Env.EnvCompareParams``; unknown keys
87+
raise ``ValueError`` at decoration time so typos can't silently disable
88+
spec-driven behaviour.
89+
90+
Applying ``@env_spec`` to a method inside a class is rejected: class tests
91+
share a single env across all their methods (that's the whole point of a
92+
class test). If one method needs a different env, lift it out into a
93+
standalone function or its own class. To declare a class-wide spec, set
94+
``env_spec = dict(...)`` as a class attribute, or decorate the class
95+
itself.
96+
"""
97+
unknown = set(kwargs) - _SPEC_KEYS
98+
if unknown:
99+
raise ValueError(
100+
"unknown env_spec keys: {}; allowed keys are: {}".format(
101+
sorted(unknown), sorted(_SPEC_KEYS)
102+
)
103+
)
104+
105+
spec = dict(kwargs)
106+
107+
def deco(target):
108+
if inspect.isfunction(target) and _looks_like_class_method(target):
109+
raise TypeError(
110+
"@env_spec is not supported on class methods (got {}). "
111+
"Class tests share one env across all methods; set "
112+
"`env_spec = dict(...)` as a class attribute, or decorate the "
113+
"class itself, or move the test out of the class.".format(
114+
target.__qualname__
115+
)
116+
)
117+
setattr(target, _ATTR, spec)
118+
return target
119+
120+
return deco
121+
122+
123+
def resolve_spec(test_func=None, owner_class=None):
124+
"""Resolve the effective env spec for a test.
125+
126+
Merges contributions from class attribute and per-function decorator
127+
(function keys override class keys).
128+
129+
Returns a dict if either layer declared a spec, otherwise ``None``.
130+
Callers use the ``None`` return as a sentinel for "no declared spec" and
131+
fall back to existing behaviour (construct ``Env`` with defaults or let
132+
the test body do it).
133+
"""
134+
declared = False
135+
spec = {}
136+
137+
if owner_class is not None:
138+
# ``@env_spec`` decoration on the class itself writes to ``_ATTR``.
139+
c = getattr(owner_class, _ATTR, None)
140+
if c is not None:
141+
declared = True
142+
spec.update(c)
143+
144+
if test_func is not None:
145+
f = getattr(test_func, _ATTR, None)
146+
if f is not None:
147+
declared = True
148+
spec.update(f)
149+
150+
return spec if declared else None
151+
152+
153+
def spec_key(spec):
154+
"""Canonical hashable key for spec equivalence.
155+
156+
Two tests with the same ``spec_key`` produce envs that satisfy
157+
``Env.compareEnvs``, so they're eligible to share a Redis instance via
158+
RLTest's opportunistic-reuse path (env.py:262). Future schedulers can use
159+
this as a grouping key.
160+
"""
161+
if spec is None:
162+
return ()
163+
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(owner_class=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(test_func=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

0 commit comments

Comments
 (0)