RLTest 0.7.24__tar.gz → 0.7.26__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: RLTest
3
- Version: 0.7.24
3
+ Version: 0.7.26
4
4
  Summary: Redis Modules Test Framework, allow to run tests on redis and modules on a variety of environments
5
5
  License: BSD-3-Clause
6
6
  License-File: LICENSE
@@ -1,10 +1,12 @@
1
1
  from RLTest.env import Env, Defaults
2
+ from RLTest.env_spec import env_spec
2
3
  from RLTest.redis_std import StandardEnv
3
4
  from ._version import __version__
4
5
 
5
6
  __all__ = [
6
7
  'Defaults',
7
8
  'Env',
8
- 'StandardEnv'
9
+ 'StandardEnv',
10
+ 'env_spec',
9
11
  ]
10
12
 
@@ -712,14 +712,23 @@ class RLTest:
712
712
  except:
713
713
  test_args = inspect.getfullargspec(test.target).args
714
714
 
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)
716
722
  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)
719
727
  except Exception as e:
720
728
  self.handleFailure(testFullName=testFullName, exception=e, prefix=msgPrefix, testname=test.name)
721
729
  return 0
722
730
 
731
+ if env is not None:
723
732
  fn = lambda: test.target(env)
724
733
  before_func = lambda: before(env)
725
734
  after_func = lambda: after(env)
@@ -832,7 +841,17 @@ class RLTest:
832
841
 
833
842
  Defaults.curr_test_name = test.name
834
843
  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()
836
855
 
837
856
  except unittest.SkipTest:
838
857
  self.printSkip(test.name)
@@ -941,44 +960,60 @@ class RLTest:
941
960
 
942
961
  self.takeEnvDown(fullShutDown=True)
943
962
 
944
- def run_jobs(jobs, results, summary, port):
963
+ def run_jobs(jobs, results, port):
945
964
  Defaults.port = port
946
- done = 0
947
965
  while True:
948
966
  try:
949
967
  test = jobs.get(timeout=0.1)
950
968
  except Exception as e:
951
969
  break
952
970
 
971
+ # Reset per-test: addFailure() in this worker writes into this
972
+ # dict, which is shipped as-is. The coordinator owns the
973
+ # cumulative testsFailed; the worker keeps no state across tests.
974
+ self.testsFailed = {}
953
975
  output = io.StringIO()
954
976
  with redirect_stdout(output):
955
977
  def on_timeout():
956
- nonlocal done
957
978
  try:
958
- done += 1
959
979
  self.killEnvWithSegFault()
960
980
  self.handleFailure(testFullName=test.name, testname=test.name, error_msg=Colors.Bred('Test timeout'))
961
981
  except Exception as e:
962
982
  self.handleFailure(testFullName=test.name, testname=test.name, error_msg=Colors.Bred('Exception on timeout function %s' % str(e)))
963
983
  finally:
964
- results.put({'test_name': test.name, "output": output.getvalue()}, block=False)
965
- summary.put({'done': done, 'failures': self.testsFailed}, block=False)
966
- # After we return the processes will be killed, so we must make sure the queues are drained properly.
984
+ # The watcher thread calls os._exit(1) right after
985
+ # this returns, bypassing Python finalization and
986
+ # the normal post-loop shutdown put. Ship both the
987
+ # per-test result and the shutdown sentinel here so
988
+ # the coordinator's bounded count of
989
+ # n_jobs + parallelism remains accurate. close() +
990
+ # join_thread() flushes both puts to the pipe first.
991
+ results.put({'test_name': test.name, 'output': output.getvalue(),
992
+ 'done': 1, 'failures': self.testsFailed,
993
+ 'shutdown': False}, block=False)
994
+ results.put({'test_name': '<worker shutdown>', 'output': '',
995
+ 'done': 0, 'failures': {},
996
+ 'shutdown': True}, block=False)
967
997
  results.close()
968
- summary.close()
969
- summary.join_thread()
970
998
  results.join_thread()
971
- done += self.run_single_test(test, on_timeout)
972
999
 
973
- results.put({'test_name': test.name, "output": output.getvalue()}, block=False)
1000
+ done_delta = self.run_single_test(test, on_timeout)
974
1001
 
975
- self.takeEnvDown(fullShutDown=True)
1002
+ results.put({'test_name': test.name, 'output': output.getvalue(),
1003
+ 'done': done_delta, 'failures': self.testsFailed,
1004
+ 'shutdown': False}, block=False)
976
1005
 
977
- # serialized the results back
978
- summary.put({'done': done, 'failures': self.testsFailed}, block=False)
1006
+ # Always ship one shutdown message per worker so the coordinator
1007
+ # reads a known total of n_jobs + parallelism messages. Captures
1008
+ # failures raised during final shutdown (e.g. "redis did not exit
1009
+ # cleanly" when env_reuse=True).
1010
+ self.testsFailed = {}
1011
+ self.takeEnvDown(fullShutDown=True)
1012
+ results.put({'test_name': '<worker shutdown>', 'output': '',
1013
+ 'done': 0, 'failures': self.testsFailed,
1014
+ 'shutdown': True}, block=False)
979
1015
 
980
1016
  results = Queue()
981
- summary = Queue()
982
1017
  # Open group for all tests at the start (parallel execution)
983
1018
  self._openGitHubActionsTestsGroup()
984
1019
  if self.parallelism == 1:
@@ -987,39 +1022,40 @@ class RLTest:
987
1022
  processes = []
988
1023
  currPort = Defaults.port
989
1024
  for i in range(self.parallelism):
990
- p = Process(target=run_jobs, args=(jobs,results,summary,currPort))
1025
+ p = Process(target=run_jobs, args=(jobs,results,currPort))
991
1026
  currPort += 30 # safe distance for cluster and replicas
992
1027
  processes.append(p)
993
1028
  p.start()
994
- for _ in self.progressbar(n_jobs):
1029
+ # Workers send exactly n_jobs per-test messages plus one shutdown
1030
+ # message each, for a known total. The single shared queue does
1031
+ # not preserve per-worker ordering, so a fast worker's shutdown
1032
+ # may arrive before a slow worker's last test. We read every
1033
+ # message in one bounded loop and tick the progressbar only on
1034
+ # per-test ones. The has_live_processor guard turns a worker
1035
+ # crash before it ships its shutdown message into a clean error
1036
+ # instead of an indefinite hang.
1037
+ def _get_result():
995
1038
  while True:
996
- # check if we have some lives executors
997
- has_live_processor = False
998
- for p in processes:
999
- if p.is_alive():
1000
- has_live_processor = True
1001
- break
1002
1039
  try:
1003
- res = results.get(timeout=1)
1004
- break
1005
- except Exception as e:
1006
- if not has_live_processor:
1007
- raise Exception('Failed to get job result and no more processors is alive')
1008
- output = res['output']
1009
- print('%s' % output, end="")
1040
+ return results.get(timeout=1)
1041
+ except Exception:
1042
+ if not any(p.is_alive() for p in processes):
1043
+ raise Exception('Failed to get job result and no more processors are alive')
1044
+
1045
+ bar_iter = iter(self.progressbar(n_jobs))
1046
+ for _ in range(n_jobs + self.parallelism):
1047
+ res = _get_result()
1048
+ if res['output']:
1049
+ print('%s' % res['output'], end="")
1050
+ done += res['done']
1051
+ self.testsFailed.update(res['failures'])
1052
+ if not res['shutdown']:
1053
+ next(bar_iter, None)
1054
+ next(bar_iter, None) # finalize bar.update(n_jobs)
1010
1055
 
1011
1056
  for p in processes:
1012
1057
  p.join()
1013
1058
 
1014
- # join results
1015
- while True:
1016
- try:
1017
- res = summary.get(timeout=1)
1018
- except Exception as e:
1019
- break
1020
- done += res['done']
1021
- self.testsFailed.update(res['failures'])
1022
-
1023
1059
  endTime = time.time()
1024
1060
 
1025
1061
  # Close group after all tests complete (parallel execution)
@@ -153,6 +153,7 @@ class Defaults:
153
153
  protocol = 2
154
154
  redis_config_file = None
155
155
  dualTLS = False
156
+ startup_grace_secs = 0.1
156
157
 
157
158
  def getKwargs(self):
158
159
  kwargs = {
@@ -201,7 +202,8 @@ class Env:
201
202
  tlsCaCertFile=None, tlsPassphrase=None, logDir=None, redisBinaryPath=None, dmcBinaryPath=None,
202
203
  redisEnterpriseBinaryPath=None, noDefaultModuleArgs=False, clusterNodeTimeout = None,
203
204
  freshEnv=False, enableDebugCommand=None, enableModuleCommand=None, enableProtectedConfigs=None, protocol=None,
204
- terminateRetries=None, terminateRetrySecs=None, redisConfigFile=None, dualTLS=False):
205
+ terminateRetries=None, terminateRetrySecs=None, redisConfigFile=None, dualTLS=False,
206
+ startupGraceSecs=None):
205
207
 
206
208
  self.testName = testName if testName else Defaults.curr_test_name
207
209
  if self.testName is None:
@@ -255,6 +257,8 @@ class Env:
255
257
 
256
258
  self.dualTLS = dualTLS if dualTLS else Defaults.dualTLS
257
259
 
260
+ self.startupGraceSecs = startupGraceSecs if startupGraceSecs is not None else Defaults.startup_grace_secs
261
+
258
262
  if not freshEnv and Env.RTestInstance and Env.RTestInstance.currEnv and self.compareEnvs(Env.RTestInstance.currEnv):
259
263
  self.envRunner = Env.RTestInstance.currEnv.envRunner
260
264
  else:
@@ -372,6 +376,7 @@ class Env:
372
376
  'terminateRetrySecs': self.terminateRetrySecs,
373
377
  'redisConfigFile': self.redisConfigFile,
374
378
  'dualTLS': self.dualTLS,
379
+ 'startupGraceSecs': self.startupGraceSecs,
375
380
  }
376
381
  return kwargs
377
382
 
@@ -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()))
@@ -3,18 +3,22 @@ import os
3
3
  import sys
4
4
  import importlib.util
5
5
  import inspect
6
+ from RLTest.env_spec import resolve_spec
6
7
  from RLTest.utils import Colors
7
8
 
8
9
 
9
10
  class TestFunction(object):
10
11
  is_class = False
11
12
 
12
- def __init__(self, filename, symbol, modulename):
13
+ def __init__(self, filename, symbol, modulename, env_spec=None):
13
14
  self.filename = filename
14
15
  self.symbol = symbol
15
16
  self.modulename = modulename
16
17
  self.is_method = False
17
18
  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
18
22
 
19
23
  def initialize(self):
20
24
  module_spec = importlib.util.spec_from_file_location(self.modulename, self.filename)
@@ -30,10 +34,12 @@ class TestFunction(object):
30
34
  class TestMethod(object):
31
35
  is_class = False
32
36
 
33
- def __init__(self, obj, name):
37
+ def __init__(self, obj, name, env_spec=None):
34
38
  self.target = obj
35
39
  self.name = name
36
40
  self.is_method = True
41
+ # Methods inherit their class's env_spec; they cannot override it.
42
+ self.env_spec = env_spec
37
43
 
38
44
  def initialize(self):
39
45
  pass
@@ -44,12 +50,13 @@ class TestMethod(object):
44
50
  class TestClass(object):
45
51
  is_class = True
46
52
 
47
- def __init__(self, filename, symbol, modulename, functions):
53
+ def __init__(self, filename, symbol, modulename, functions, env_spec=None):
48
54
  self.filename = filename
49
55
  self.symbol = symbol
50
56
  self.modulename = modulename
51
57
  self.functions = functions
52
58
  self.name = '{}:{}'.format(self.modulename, symbol)
59
+ self.env_spec = env_spec
53
60
 
54
61
  def initialize(self):
55
62
  module_spec = importlib.util.spec_from_file_location(self.modulename, self.filename)
@@ -70,7 +77,8 @@ class TestClass(object):
70
77
  if not callable(bound):
71
78
  continue
72
79
  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))
74
82
  return fns
75
83
 
76
84
 
@@ -129,9 +137,15 @@ class TestLoader(object):
129
137
  if inspect.isclass(obj):
130
138
  methnames = [mname for mname in dir(obj)
131
139
  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
+ )
133
144
  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
+ )
135
149
  except OSError as e:
136
150
  print(Colors.Red("Can't access file %s." % filename))
137
151
  raise e
@@ -23,7 +23,7 @@ class StandardEnv(object):
23
23
  useAof=False, useRdbPreamble=True, debugger=None, sanitizer=None, noCatch=False, noLog=False, unix=False, verbose=False, useTLS=False,
24
24
  tlsCertFile=None, tlsKeyFile=None, tlsCaCertFile=None, clusterNodeTimeout=None, tlsPassphrase=None, enableDebugCommand=False, protocol=2,
25
25
  terminateRetries=None, terminateRetrySecs=None, enableProtectedConfigs=False, enableModuleCommand=False, loglevel=None,
26
- redisConfigFile=None, dualTLS=False
26
+ redisConfigFile=None, dualTLS=False, startupGraceSecs=0.1
27
27
  ):
28
28
  self.uuid = uuid.uuid4().hex
29
29
  self.redisBinaryPath = os.path.expanduser(redisBinaryPath) if redisBinaryPath.startswith(
@@ -72,6 +72,7 @@ class StandardEnv(object):
72
72
  self.terminateRetrySecs = terminateRetrySecs
73
73
  self.redisConfigFile = redisConfigFile
74
74
  self.dualTLS = dualTLS
75
+ self.startupGraceSecs = startupGraceSecs
75
76
 
76
77
  if port > 0:
77
78
  self.port = port
@@ -381,7 +382,7 @@ class StandardEnv(object):
381
382
  if masters and self.masterProcess is None:
382
383
  self.masterProcess = subprocess.Popen(args=self.masterCmdArgs, env=self.masterOSEnv, cwd=self.dbDirPath,
383
384
  **options)
384
- time.sleep(0.1)
385
+ time.sleep(self.startupGraceSecs)
385
386
  if self._isAlive(self.masterProcess):
386
387
  con = self.getConnection()
387
388
  self.waitForRedisToStart(con, self.masterProcess)
@@ -392,7 +393,7 @@ class StandardEnv(object):
392
393
  print(Colors.Green("Redis slave command: " + ' '.join(self.slaveCmdArgs)))
393
394
  self.slaveProcess = subprocess.Popen(args=self.slaveCmdArgs, env=self.slaveOSEnv, cwd=self.dbDirPath,
394
395
  **options)
395
- time.sleep(0.1)
396
+ time.sleep(self.startupGraceSecs)
396
397
  if self._isAlive(self.slaveProcess):
397
398
  con = self.getSlaveConnection()
398
399
  self.waitForRedisToStart(con, self.slaveProcess)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "RLTest"
3
- version = "0.7.24"
3
+ version = "0.7.26"
4
4
  description="Redis Modules Test Framework, allow to run tests on redis and modules on a variety of environments"
5
5
  authors = ["Redis, Inc. <oss@redis.com>"]
6
6
  license = "BSD-3-Clause"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes