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
4 changes: 3 additions & 1 deletion RLTest/__init__.py
Original file line number Diff line number Diff line change
@@ -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',
]

27 changes: 23 additions & 4 deletions RLTest/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
141 changes: 141 additions & 0 deletions RLTest/env_spec.py
Original file line number Diff line number Diff line change
@@ -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.<locals>.g -> nested function (not a method)
C.m -> class method
outer.<locals>.C.m -> class defined inside a function; still a method

The rule: take whatever follows the last ``<locals>.`` (the path *inside*
the innermost enclosing function scope, or the whole qualname if there's
no ``<locals>``). 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('<locals>.', 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()))
26 changes: 20 additions & 6 deletions RLTest/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions tests/unit/test_env_spec.py
Original file line number Diff line number Diff line change
@@ -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.<locals>.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) == ()
Loading