diff --git a/.github/workflows/biweekly.yml b/.github/workflows/biweekly.yml new file mode 100644 index 00000000..56ebb6f1 --- /dev/null +++ b/.github/workflows/biweekly.yml @@ -0,0 +1,26 @@ +name: Biweekly + +on: + schedule: + - cron: "0 0 1,15 * *" # almost biweekly, twice a month + +jobs: + build: + uses: ./.github/workflows/ci.yml + secrets: inherit + + notify-failure: + needs: + - build + if: failure() + runs-on: ubuntu-latest + steps: + - name: Notify Failure + uses: slackapi/slack-github-action@v1 + with: + payload: | + { + "failed_workflow": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.RQE_NOTIFY_NIGHTLY_FAIL_HOOK }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 180c8425..c6729c36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,202 +5,228 @@ on: branches: - master pull_request: - schedule: - - cron: "0 0 * * *" + workflow_call: + +concurrency: + # if the event is a pull request, use the PR number to make PR checks cancel previous runs + # if the event is not a pull request, use the run number to make each run unique + group: CI-${{ github.event_name == 'pull_request' && github.event.number || github.run_number }} + cancel-in-progress: true jobs: - build-ubuntu: - name: Test on ${{ matrix.platform }} with Python ${{ matrix.python }} + build: + name: Test on ${{ matrix.platform }} with Python ${{ matrix.python }} with Redis ${{ matrix.redis-version }} runs-on: ${{ matrix.platform }} timeout-minutes: 40 strategy: + fail-fast: ${{ github.event_name == 'pull_request' }} matrix: - platform: ['ubuntu-20.04', 'ubuntu-18.04', 'ubuntu-16.04'] - python: ['2.7', '3.6', '3.7', '3.8', '3.9'] + platform: ['ubuntu-latest', 'macos-latest'] + python: ['3.10', '3.14'] # min live version, latest testing + redis-version: ['7.4', '8.2'] + # ubuntu-latest no longer supports python 3.7, macos-latest no longer supports python 3.10 + include: + - platform: ubuntu-22.04 + python: '3.7' + redis-version: '7.4' + poetry-version: '1.5.1' + - platform: macos-latest + python: '3.11' + redis-version: '8.2' + exclude: + - platform: macos-latest + python: '3.10' + defaults: + run: + shell: bash -l -eo pipefail {0} steps: - - uses: actions/checkout@v2 + - name: checkout + uses: actions/checkout@v4 # Number of commits to fetch. 0 indicates all history for all branches and tags. with: fetch-depth: '' - - name: Install OpenSSL development libraries - run: | - sudo apt-get install -y libssl-dev + - name: clone redis + uses: actions/checkout@v4 + # Number of commits to fetch. 0 indicates all history for all branches and tags. + with: + fetch-depth: '' + repository: 'redis/redis' + ref: ${{matrix.redis-version}} + path: redis - name: Setup Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} architecture: x64 - - name: Cache pip - uses: actions/cache@v1 + - name: Setup Poetry + uses: snok/install-poetry@v1 with: - path: ~/.cache/pip # This path is specific to Ubuntu + version: ${{ matrix.poetry-version || '2.2.1' }} + virtualenvs-in-project: true + virtualenvs-create: true + installer-parallel: true + + - name: Cache poetry + uses: actions/cache@v4 + with: + path: .venv # Look to see if there is a cache hit for the corresponding requirements file key: ${{ matrix.platform }}-${{ matrix.python }}-pyproject.toml-${{ hashFiles('pyproject.toml') }} - restore-keys: | - ${{ matrix.platform }}-${{ matrix.python }}-pyproject.toml-${{hashFiles('pyproject.toml')}}} - name: Install Python dependencies - run: | - sudo apt-get install -y python-setuptools python3-setuptools - pip install poetry - poetry config virtualenvs.create false - poetry export --dev --without-hashes -o requirements-${{matrix.platform}}-${{matrix.python}}.txt - pip install -r requirements-${{matrix.platform}}-${{matrix.python}}.txt - - - name: Cache Redis - id: cache-redis - uses: actions/cache@v1 - with: - path: redis - key: ${{ matrix.platform }}-${{ matrix.python }}-redis - restore-keys: | - ${{ matrix.platform }}-${{ matrix.python }}-redis + run: poetry install - - name: Install Redis Server test dependencies - if: steps.cache-redis.outputs.cache-hit != 'true' - run: | - git clone https://github.com/redis/redis.git --branch unstable --depth 1 - cd redis - make BUILD_TLS=yes -j - ./src/redis-server --version - cd .. - - name: Generate test certificates - run: | - cd redis - rm -rf ./tests/unit/tls/ - ./utils/gen-test-certs.sh + - name: Install Redis Server + working-directory: redis + run: make BUILD_TLS=yes -j `nproc` install - - name: Unit Test with pytest - timeout-minutes: 10 + - name: Generate test certificates #this step needs the redis repo to be cloned + working-directory: redis + run: ./utils/gen-test-certs.sh + + - name: Copy certificates to tests/flow run: | - TLS_CERT=./redis/tests/tls/redis.crt \ - TLS_KEY=./redis/tests/tls/redis.key \ - TLS_CACERT=./redis/tests/tls/ca.crt \ - REDIS_BINARY=./redis/src/redis-server \ - pytest --ignore=tests/flow --ignore=test_example.py + mkdir -p tests/flow/tls + cp redis/tests/tls/redis.crt tests/flow/tls + cp redis/tests/tls/redis.key tests/flow/tls + cp redis/tests/tls/ca.crt tests/flow/tls - - name: Install RLTest + - name: Unit Test with pytest + timeout-minutes: 30 run: | - pip install . + TLS="tests/flow/tls" + TLS_CERT=$TLS/redis.crt \ + TLS_KEY=$TLS/redis.key \ + TLS_CACERT=$TLS/ca.crt \ + REDIS_BINARY=`command -v redis-server` \ + poetry run pytest --ignore=tests/flow --ignore=test_example.py -v - name: Flow Test OSS Single Module + working-directory: tests/flow run: | - cd tests/flow make -C modules - CONTAINS_MODULES=1 RLTest --env oss -v --clear-logs --oss-redis-path ../../redis/src/redis-server \ - --module modules/module1.so - cd .. + poetry run RLTest --env oss -v --clear-logs --module modules/module1.so --module-args "DUPLICATE_POLICY BLOCK" - name: Flow Test OSS Multiple Modules --use-slaves + working-directory: tests/flow + if: (success() || failure()) run: | - cd tests/flow make -C modules - CONTAINS_MODULES=1 RLTest --env oss -v --clear-logs --oss-redis-path ../../redis/src/redis-server \ - --module modules/module1.so --module-args '' --module modules/module2.so --module-args '' \ + poetry run RLTest --env oss -v --clear-logs \ + --module modules/module1.so --module-args '' \ + --module modules/module2.so --module-args '' \ --use-slaves - cd .. - name: Flow Test OSS Multiple Modules --use-aof + working-directory: tests/flow + if: (success() || failure()) run: | - cd tests/flow make -C modules - CONTAINS_MODULES=1 RLTest --env oss -v --clear-logs --oss-redis-path ../../redis/src/redis-server \ - --module modules/module1.so --module-args '' --module modules/module2.so --module-args '' \ - --use-aof - cd .. + poetry run RLTest --env oss -v --clear-logs \ + --module modules/module1.so --module-args '' \ + --module modules/module2.so --module-args '' \ + --use-aof - name: Flow Test OSS Multiple Modules + working-directory: tests/flow + if: (success() || failure()) run: | - cd tests/flow make -C modules - CONTAINS_MODULES=1 RLTest --env oss -v --clear-logs --oss-redis-path ../../redis/src/redis-server \ - --module modules/module1.so --module-args '' --module modules/module2.so --module-args '' - cd .. + poetry run RLTest --env oss -v --clear-logs \ + --module modules/module1.so --module-args '' \ + --module modules/module2.so --module-args '' - name: Flow Test OSS-CLUSTER Modules + working-directory: tests/flow + if: (success() || failure()) run: | - cd tests/flow make -C modules - CONTAINS_MODULES=1 RLTest --env oss-cluster -v --clear-logs --oss-redis-path ../../redis/src/redis-server \ - --module modules/module1.so --module-args '' --module modules/module2.so --module-args '' - cd .. + poetry run RLTest --env oss-cluster -v --clear-logs \ + --module modules/module1.so --module-args '' \ + --module modules/module2.so --module-args '' - name: Flow Test OSS TCP - run: | - cd tests/flow - RLTest --env oss -v --clear-logs --oss-redis-path ../../redis/src/redis-server - cd .. + working-directory: tests/flow + if: (success() || failure()) + run: poetry run RLTest --env oss -v --clear-logs - name: Flow Test OSS UNIX SOCKETS - run: | - cd tests/flow - RLTest --env oss -v --clear-logs --oss-redis-path ../../redis/src/redis-server - cd .. + working-directory: tests/flow + if: (success() || failure()) + run: poetry run RLTest --env oss -v --clear-logs - name: Flow Test OSS TCP SLAVES - run: | - cd tests/flow - RLTest --env oss -v --unix --clear-logs --oss-redis-path ../../redis/src/redis-server - cd .. + working-directory: tests/flow + if: (success() || failure()) + run: poetry run RLTest --env oss -v --unix --clear-logs - name: Flow Test OSS-CLUSTER TCP - run: | - cd tests/flow - RLTest --env oss-cluster -v --clear-logs --shards-count 3 --oss-redis-path ../../redis/src/redis-server - cd .. + working-directory: tests/flow + if: (success() || failure()) + run: poetry run RLTest --env oss-cluster -v --clear-logs --shards-count 3 - name: Flow Test OSS TCP with TLS - run: | - cd tests/flow - RLTest --env oss -v --clear-logs \ - --oss-redis-path ../../redis/src/redis-server \ - --tls-cert-file ../../redis/tests/tls/redis.crt \ - --tls-key-file ../../redis/tests/tls/redis.key \ - --tls-ca-cert-file ../../redis/tests/tls/ca.crt \ + working-directory: tests/flow + if: (success() || failure()) + run: | + TLS="tls" + poetry run RLTest --env oss -v --clear-logs \ + --tls-cert-file $TLS/redis.crt \ + --tls-key-file $TLS/redis.key \ + --tls-ca-cert-file $TLS/ca.crt \ --tls - cd .. - name: Flow Test OSS-CLUSTER with TLS - run: | - cd tests/flow - RLTest --env oss-cluster --shards-count 3 -v --clear-logs \ - --oss-redis-path ../../redis/src/redis-server \ - --tls-cert-file ../../redis/tests/tls/redis.crt \ - --tls-key-file ../../redis/tests/tls/redis.key \ - --tls-ca-cert-file ../../redis/tests/tls/ca.crt \ + working-directory: tests/flow + if: (success() || failure()) + run: | + TLS="tls" + poetry run RLTest --env oss-cluster --shards-count 3 -v --clear-logs \ + --tls-cert-file $TLS/redis.crt \ + --tls-key-file $TLS/redis.key \ + --tls-ca-cert-file $TLS/ca.crt \ --tls - cd .. - name: Flow Test OSS-CLUSTER with SLAVES and TLS - run: | - cd tests/flow - RLTest --env oss-cluster --shards-count 3 --use-slaves -v --clear-logs \ - --oss-redis-path ../../redis/src/redis-server \ - --tls-cert-file ../../redis/tests/tls/redis.crt \ - --tls-key-file ../../redis/tests/tls/redis.key \ - --tls-ca-cert-file ../../redis/tests/tls/ca.crt \ + working-directory: tests/flow + if: (success() || failure()) + run: | + TLS="tls" + poetry run RLTest --env oss-cluster --shards-count 3 --use-slaves -v --clear-logs \ + --tls-cert-file $TLS/redis.crt \ + --tls-key-file $TLS/redis.key \ + --tls-ca-cert-file $TLS/ca.crt \ --tls - cd ../.. - name: Generate coverage report - if: matrix.platform == 'ubuntu-18.04' && matrix.python == '3.6' + if: matrix.python == '3.14' && matrix.platform == 'ubuntu-latest' run: | - TLS_CERT=./redis/tests/tls/redis.crt \ - TLS_KEY=./redis/tests/tls/redis.key \ - TLS_CACERT=./redis/tests/tls/ca.crt \ - REDIS_BINARY=./redis/src/redis-server \ - pytest --ignore=tests/flow --ignore=test_example.py --cov-config=.coveragerc --cov-report=xml --cov=RLTest + TLS="tests/flow/tls" + TLS_CERT=$TLS/redis.crt \ + TLS_KEY=$TLS/redis.key \ + TLS_CACERT=$TLS/ca.crt \ + REDIS_BINARY=`command -v redis-server` \ + poetry run pytest --ignore=tests/flow --ignore=test_example.py --cov-config=.coveragerc --cov-report=xml --cov=RLTest - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 - if: matrix.platform == 'ubuntu-18.04' && matrix.python == '3.6' + uses: codecov/codecov-action@v4 + if: matrix.python == '3.14' && matrix.platform == 'ubuntu-latest' + continue-on-error: true with: token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage.xml flags: unittests name: codecov-umbrella fail_ci_if_error: true + + pr-validation: + needs: [build] + runs-on: ubuntu-latest + if: ${{ !cancelled() }} + steps: + - if: contains(needs.*.result, 'failure') + run: exit 1 diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 67b2694e..c6485215 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -14,45 +14,24 @@ jobs: - name: get version from tag id: get_version - run: | - realversion="${GITHUB_REF/refs\/tags\//}" - realversion="${realversion//v/}" - echo "::set-output name=VERSION::$realversion" - - - name: Set the version for publishing - uses: ciiiii/toml-editor@1.0.0 - with: - file: "pyproject.toml" - key: "tool.poetry.version" - value: "${{ steps.get_version.outputs.VERSION }}" - - - name: Set up Python 3.7 - uses: actions/setup-python@v1 - with: - python-version: 3.7 + env: + TAG: ${{ github.event.release.tag_name }} + run: echo "VERSION=${TAG#v}" >> $GITHUB_OUTPUT - name: Install Poetry - uses: dschep/install-poetry-action@v1.3 + run: pipx install poetry - - name: Cache Poetry virtualenv - uses: actions/cache@v1 - id: cache + - name: Set up Python 3.12 + uses: actions/setup-python@v5 with: - path: ~/.virtualenvs - key: poetry-${{ hashFiles('**/poetry.lock') }} - restore-keys: | - poetry-${{ hashFiles('**/poetry.lock') }} + python-version: 3.12 + cache: 'poetry' - - name: Set Poetry config + - name: Install Python dependencies run: | - poetry config virtualenvs.in-project false - poetry config virtualenvs.path ~/.virtualenvs - - - name: Install Dependencies - run: poetry install - if: steps.cache.outputs.cache-hit != 'true' + poetry version ${{ steps.get_version.outputs.VERSION }} + poetry install - name: Publish to PyPI - if: github.event_name == 'release' run: | poetry publish -u __token__ -p ${{ secrets.PYPI_TOKEN }} --build diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index ec2d88bf..f6d4ba46 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: # Drafts your next Release notes as Pull Requests are merged into "master" - - uses: release-drafter/release-drafter@v5 + - uses: release-drafter/release-drafter@v6 with: # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml config-name: release-drafter-config.yml diff --git a/.gitignore b/.gitignore index 59904210..6c9d09f5 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,6 @@ htmlcov/ .coverage .coverage.* .cache -nosetests.xml coverage.xml *.cover .hypothesis/ @@ -112,3 +111,6 @@ venv.bak/ # vscode .vscode + +# Misc +/1/ diff --git a/README.md b/README.md index b6350af3..7fbd8cd4 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,10 @@ optional arguments: --oss-redis-path OSS_REDIS_PATH path to the oss redis binary (default: redis-server) --enterprise-redis-path ENTERPRISE_REDIS_PATH - path to the entrprise redis binary (default: + path to the enterprise redis binary (default: ~/.RLTest/opt/redislabs/bin/redis-server) + --redis-config-file REDIS_CONFIG_FILE + path to the redis configuration file (default: None) --stop-on-failure stop running on failure (default: False) -x, --exit-on-failure Stop test execution and exit on first assertion @@ -161,7 +163,7 @@ optional arguments: --unix Use Unix domain sockets instead of TCP (default: False) --randomize-ports Randomize Redis listening port assignment rather - thanusing default port (default: False) + than using default port (default: False) --collect-only Collect the tests and exit (default: False) --tls Enable TLS Support and disable the non-TLS port completely. TLS connections will be available at the @@ -173,6 +175,10 @@ optional arguments: --tls-ca-cert-file TLS_CA_CERT_FILE /path/to/ca.crt (default: None) + --dualTLS Initialize both TLS and non-TLS ports for all shards. + The non-TLS ports will be the TLS ports + 1500. + Only effective when TLS is active (see `useTLS`). + ``` ## Sample usages diff --git a/RLTest.sh b/RLTest.sh deleted file mode 100755 index 071ead74..00000000 --- a/RLTest.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" - -if [[ -d venv ]]; then - . $HERE/venv/bin/activate -else - [[ ! `command -v virtualenv` ]] && pip install virtualenv - python -m virtualenv --system-site-packages venv - . $HERE/venv/bin/activate - pip install -r $HERE/requirements.txt -fi -PYTHONPATH=$HERE/RLTest python -m RLTest "$@" 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 f9cff9ce..9bb7143b 100644 --- a/RLTest/__main__.py +++ b/RLTest/__main__.py @@ -1,6 +1,7 @@ from __future__ import print_function import argparse +import io import os import cmd import traceback @@ -10,14 +11,19 @@ import unittest import time import shlex -from multiprocessing import Process, Queue +import json +from multiprocessing import Process, Queue, set_start_method from RLTest.env import Env, TestAssertionFailure, Defaults -from RLTest.utils import Colors, fix_modules, fix_modulesArgs +from RLTest.utils import Colors, fix_modules, fix_modulesArgs, is_github_actions from RLTest.loader import TestLoader from RLTest.Enterprise import binaryrepo from RLTest import debuggers from RLTest._version import __version__ +from contextlib import redirect_stdout +from progressbar import progressbar, ProgressBar +import threading +import signal import warnings warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -118,6 +124,10 @@ def do_normal_conn(self, line): '--env', '-e', default='oss', choices=['oss', 'oss-cluster', 'enterprise', 'enterprise-cluster', 'existing-env', 'cluster_existing-env'], help='env on which to run the test') +parser.add_argument( + '-p', '--redis-port', type=int, default=6379, + help='Redis server port') + parser.add_argument( '--existing-env-addr', default='localhost:6379', help='Address of existing env, relevent only when running with existing-env, cluster_existing-env') @@ -138,6 +148,11 @@ def do_normal_conn(self, line): '--cluster_node_timeout', default=5000, help='sets the node timeout on cluster in milliseconds') +parser.add_argument( + '--cluster-start-timeout', default=40, type=int, + help='timeout in seconds to wait for cluster to be ready (default 40 seconds). ' + 'Increase for large shard counts (e.g., 99 shards).') + parser.add_argument( '--cluster_credentials', help='enterprise cluster cluster_credentials "username:password", relevent only when running with cluster_existing-env') @@ -152,7 +167,11 @@ def do_normal_conn(self, line): parser.add_argument( '--enterprise-redis-path', default=os.path.join(binaryrepo.REPO_ROOT, 'opt/redislabs/bin/redis-server'), - help='path to the entrprise redis binary') + help='path to the enterprise redis binary') + +parser.add_argument( + '--redis-config-file', default=None, + help='path to the redis configuration file') parser.add_argument( '--stop-on-failure', action='store_const', const=True, default=False, @@ -171,7 +190,13 @@ def do_normal_conn(self, line): help='stop before each test allow gdb attachment') parser.add_argument( - '-t', '--test', help='Specify test to run, in the form of "file:test"') + '-t', '--test', metavar='TEST', action='append', help='test to run, in the form of "file:test"') + +parser.add_argument( + '-f', '--tests-file', metavar='FILE', action='append', help='file containing test to run, in the form of "file:test"') + +parser.add_argument( + '-F', '--failed-tests-file', metavar='FILE', help='destination file for failed tests') parser.add_argument( '--env-only', action='store_const', const=True, default=False, @@ -179,12 +204,16 @@ def do_normal_conn(self, line): parser.add_argument( '--clear-logs', action='store_const', const=True, default=False, - help='deleting the log direcotry before the execution') + help='deleting the log directory before the execution') parser.add_argument( '--log-dir', default='./logs', help='directory to write logs to') +parser.add_argument( + '--log-level', default=None, metavar='LEVEL', choices=['debug', 'verbose', 'notice', 'warning'], + help='sets the server log level') + parser.add_argument( '--use-slaves', action='store_const', const=True, default=False, help='run env with slaves enabled') @@ -193,6 +222,10 @@ def do_normal_conn(self, line): '--shards-count', default=1, type=int, help='Number shards in bdb') +parser.add_argument( + '--test-timeout', default=0, type=int, + help='Test timeout, 0 means no timeout.') + parser.add_argument( '--download-enterprise-binaries', action='store_const', const=True, default=False, help='run env with slaves enabled') @@ -240,6 +273,10 @@ def do_normal_conn(self, line): "By default on RLTest the return value from Valgrind will be used to fail the tests." "Use this option when you wish to dry-run valgrind but not fail the test on valgrind reported errors." ) + +parser.add_argument( + '--sanitizer', default=None, help='type of CLang sanitizer (addr|mem)') + parser.add_argument( '-i', '--interactive-debugger', action='store_const', const=True, default=False, help='runs the redis on a debuger (gdb/lldb) interactivly.' @@ -252,7 +289,31 @@ def do_normal_conn(self, line): parser.add_argument( '-s', '--no-output-catch', action='store_const', const=True, default=False, - help='all output will be written to the stdout, no log files.') + help='all output will be written to the stdout, no log files. Implies --no-progress.') + +parser.add_argument( + '--no-progress', action='store_const', const=True, default=False, + help='Do not show progress bar.') + +parser.add_argument( + '--verbose-information-on-failure', action='store_const', const=True, default=False, + help='Print a verbose information on test failure') + +parser.add_argument( + '--enable-debug-command', action='store_const', const=True, default=False, + help='On Redis 7, debug command need to be enabled in order to be used.') + +parser.add_argument( + '--enable-protected-configs', action='store_const', const=True, default=False, + help='On Redis 7, this option needs to be enabled in order to change protected configuration in runtime.') + +parser.add_argument( + '--enable-module-command', action='store_const', const=True, default=False, + help='On Redis 7, this option needs to be enabled in order to use module command (load/unload modules in runtime).') + +parser.add_argument( + '--allow-unsafe', action='store_const', const=True, default=False, + help='On Redis 7, allow the three unsafe modes above (debug and module commands and protected configs)') parser.add_argument('--check-exitcode', help='Check redis process exit code', default=False, action='store_true') @@ -283,6 +344,9 @@ def do_normal_conn(self, line): parser.add_argument( '--tls-ca-cert-file', default=None, help='/path/to/ca.crt') +parser.add_argument( + '--tls-passphrase', default=None, help='passphrase to use on decript key file') + class EnvScopeGuard: def __init__(self, runner): self.runner = runner @@ -293,6 +357,64 @@ def __enter__(self): def __exit__(self, type, value, traceback): self.runner.takeEnvDown() +class TestTimeLimit(object): + """ + A test timeout watcher. The watcher opens thread that sleep for the + required timeout and then wake up and send SIGUSR1 signal to the main thread + causing it to enter a timeout phase. When enter a timeout phase, the main thread + prints its trace and enter a deep sleep. The watcher thread continue collecting + environment stats and when done kills the processes. + """ + + def __init__(self, timeout, timeout_func): + self.timeout = timeout + self.timeout_time = time.time() + self.timeout + self.timeout_func = timeout_func + self.condition = threading.Condition() + self.thread = None + self.is_done = False + self.trace_printed = False + + def on_timeout(self, signum, frame): + for line in traceback.format_stack(): + print(line.strip()) + self.trace_printed = True + time.sleep(1000) # sleep forever process will be killed soon + + def watcher_thread(self): + self.condition.acquire() + while not self.is_done and self.timeout_time > time.time(): + self.condition.wait(timeout=0.1) + if not self.is_done: + print(Colors.Bred('Test Timeout, printing trace.')) + os.kill(os.getpid(), signal.SIGUSR1) + while not self.trace_printed: + time.sleep(0.1) + try: + self.timeout_func() + except Exception as e: + print(Colors.Bred("Failed on timeout function, %s" % str(e))) + os._exit(1) + + def reset(self): + self.timeout_time = time.time() + self.timeout + + def __enter__(self): + if self.timeout == 0: + return self + signal.signal(signal.SIGUSR1, self.on_timeout) + self.thread = threading.Thread(target=self.watcher_thread) + self.thread.start() + return self + + def __exit__(self, exc_type, exc_value, traceback): + if self.timeout == 0: + return + self.condition.acquire() + self.is_done = True + self.condition.notify(1) + self.condition.release() + class RLTest: def __init__(self): @@ -310,6 +432,10 @@ def __init__(self): print(Colors.Green('RLTest version {}'.format(__version__))) sys.exit(0) + if self.args.redis_port not in range(1, pow(2, 16)): + print(Colors.Bred(f'requested port {self.args.redis_port} is not valid')) + sys.exit(1) + if self.args.interactive_debugger: if self.args.env != 'oss' and not (self.args.env == 'oss-cluster' and Defaults.num_shards == 1) and self.args.env != 'enterprise': print(Colors.Bred('interactive debugger can only be used on non cluster env')) @@ -361,6 +487,10 @@ def __init__(self): elif self.args.interactive_debugger: debugger = debuggers.default_interactive_debugger + sanitizer = None + if self.args.sanitizer: + sanitizer = self.args.sanitizer + if self.args.env.endswith('existing-env'): # when running on existing env we always reuse it self.args.env_reuse = True @@ -374,12 +504,17 @@ def __init__(self): print(Colors.Bred('Using `--module` multiple time implies that you specify the `--module-args` in the the same number')) sys.exit(1) + if self.args.no_output_catch and self.args.parallelism > 1: + print(Colors.Bred('--no-output-catch can not be combined with --parallelism.')) + sys.exit(1) + Defaults.module = fix_modules(self.args.module) Defaults.module_args = fix_modulesArgs(Defaults.module, self.args.module_args) Defaults.env = self.args.env Defaults.binary = self.args.oss_redis_path Defaults.verbose = self.args.verbose Defaults.logdir = self.args.log_dir + Defaults.loglevel = self.args.log_level Defaults.use_slaves = self.args.use_slaves Defaults.num_shards = self.args.shards_count Defaults.shards_ports = self.args.shards_ports.split(',') if self.args.shards_ports is not None else None @@ -393,8 +528,11 @@ def __init__(self): Defaults.debug_pause = self.args.debug Defaults.debug_print = self.args.debug_print Defaults.no_capture_output = self.args.no_output_catch + Defaults.print_verbose_information_on_failure = self.args.verbose_information_on_failure Defaults.debugger = debugger + Defaults.sanitizer = sanitizer Defaults.exit_on_failure = self.args.exit_on_failure + Defaults.port = self.args.redis_port Defaults.external_addr = self.args.existing_env_addr Defaults.use_unix = self.args.unix Defaults.randomize_ports = self.args.randomize_ports @@ -402,18 +540,47 @@ def __init__(self): Defaults.tls_cert_file = self.args.tls_cert_file Defaults.tls_key_file = self.args.tls_key_file Defaults.tls_ca_cert_file = self.args.tls_ca_cert_file + Defaults.tls_passphrase = self.args.tls_passphrase Defaults.oss_password = self.args.oss_password Defaults.cluster_node_timeout = self.args.cluster_node_timeout + Defaults.cluster_start_timeout = self.args.cluster_start_timeout + if Defaults.cluster_start_timeout < 5: + raise Exception('--cluster-start-timeout must be at least 5 seconds') + Defaults.enable_debug_command = True if self.args.allow_unsafe else self.args.enable_debug_command + Defaults.enable_protected_configs = True if self.args.allow_unsafe else self.args.enable_protected_configs + Defaults.enable_module_command = True if self.args.allow_unsafe else self.args.enable_module_command + Defaults.redis_config_file = self.args.redis_config_file + if Defaults.use_unix and Defaults.use_slaves: raise Exception('Cannot use unix sockets with slaves') + if Defaults.env == 'enterprise-cluster' and Defaults.redis_config_file is not None: + raise Exception('Redis configuration file is not supported with enterprise-cluster env') + self.tests = [] - self.testsFailed = [] + self.testsFailed = {} self.currEnv = None self.loader = TestLoader() - if self.args.test: + + # For GitHub Actions grouping - track if we have an open group + self.github_actions_group_open = False if is_github_actions() else None + if self.args.test is not None: self.loader.load_spec(self.args.test) - else: + if self.args.tests_file is not None: + for fname in self.args.tests_file: + try: + with open(fname, 'r') as file: + for line in file.readlines(): + line = line.strip() + if line.startswith('#') or line == "": + continue + try: + self.loader.load_spec(line) + except: + print(Colors.Red('Invalid test {TEST} in file {FILE}'.format(TEST=line, FILE=fname))) + except: + print(Colors.Red('Test file {} not found'.format(fname))) + if self.args.test is None and self.args.tests_file is None: self.loader.scan_dir(os.getcwd()) if self.args.collect_only: @@ -429,6 +596,11 @@ def __init__(self): def _convertArgsType(self): pass + def stopEnvWithSegFault(self): + if not self.currEnv: + return + self.currEnv.stopEnvWithSegFault() + def takeEnvDown(self, fullShutDown=False): if not self.currEnv: return @@ -444,10 +616,14 @@ def takeEnvDown(self, fullShutDown=False): env=self.currEnv) if needShutdown: + flush_ok = True if self.currEnv.isUp(): - self.currEnv.flush() + try: + self.currEnv.flush() + except: + flush_ok = False self.currEnv.stop() - if self.require_clean_exit and self.currEnv and not self.currEnv.checkExitCode(): + if self.require_clean_exit and self.currEnv and (not self.currEnv.checkExitCode() or not flush_ok): print(Colors.Bred('\tRedis did not exit cleanly')) self.addFailure(self.currEnv.testName, ['redis process failure']) if self.args.check_exitcode: @@ -480,15 +656,12 @@ def addFailure(self, name, failures=None): failures = [failures] if not failures: failures = [] - self.testsFailed.append([name, failures]) + self.testsFailed.setdefault(name, []).extend(failures) - def getTotalFailureCount(self): - ret = 0 - for _, failures in self.testsFailed: - ret += len(failures) - return ret + def getFailedTestsCount(self): + return len(self.testsFailed) - def handleFailure(self, testFullName=None, exception=None, prefix='', testname=None, env=None): + def handleFailure(self, testFullName=None, exception=None, prefix='', testname=None, env=None, error_msg=None): """ Failure omni-function. @@ -518,12 +691,14 @@ def handleFailure(self, testFullName=None, exception=None, prefix='', testname=N self.addFailuresFromEnv(testname, env) elif exception: self.addFailure(testname, str(exception)) + elif error_msg: + self.addFailure(testname, str(error_msg)) else: self.addFailure(testname, '') - def _runTest(self, test, numberOfAssertionFailed=0, prefix='', before=None, after=None): + def _runTest(self, test, numberOfAssertionFailed=0, prefix='', before=lambda x=None: None, after=lambda x=None: None): test.initialize() - + msgPrefix = test.name testFullName = prefix + test.name @@ -531,16 +706,32 @@ def _runTest(self, test, numberOfAssertionFailed=0, prefix='', before=None, afte if not test.is_method: Defaults.curr_test_name = testFullName - if len(inspect.getargspec(test.target).args) > 0 and not test.is_method: + try: + # Python < 3.11 + test_args = inspect.getargspec(test.target).args + except: + test_args = inspect.getfullargspec(test.target).args + + # 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) + 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)) if before is not None else None - after_func = (lambda: after(env)) if after is not None else None + before_func = lambda: before(env) + after_func = lambda: after(env) else: fn = test.target before_func = before @@ -548,8 +739,7 @@ def _runTest(self, test, numberOfAssertionFailed=0, prefix='', before=None, afte hasException = False try: - if before_func: - before_func() + before_func() fn() passed = True except unittest.SkipTest: @@ -564,7 +754,7 @@ def _runTest(self, test, numberOfAssertionFailed=0, prefix='', before=None, afte except Exception as err: if self.args.exit_on_failure: self.takeEnvDown(fullShutDown=True) - after = None + after_func = lambda x=None: None raise self.handleFailure(testFullName=testFullName, exception=err, prefix=msgPrefix, @@ -572,8 +762,7 @@ def _runTest(self, test, numberOfAssertionFailed=0, prefix='', before=None, afte hasException = True passed = False finally: - if after_func: - after_func() + after_func() numFailed = 0 if self.currEnv: @@ -596,8 +785,22 @@ def _runTest(self, test, numberOfAssertionFailed=0, prefix='', before=None, afte if passed: self.printPass(testFullName) + if hasException: + numFailed += 1 # exception should be counted as failure return numFailed + def _openGitHubActionsTestsGroup(self): + """Open a GitHub Actions group wrapping all tests""" + if self.github_actions_group_open is False: + print('::group::📋 Test Execution') + self.github_actions_group_open = True + + def _closeGitHubActionsTestsGroup(self): + """Close the GitHub Actions tests group if one is open""" + if self.github_actions_group_open is True: + print('::endgroup::') + self.github_actions_group_open = False + def printSkip(self, name): print('%s:\r\n\t%s' % (Colors.Cyan(name), Colors.Green('[SKIP]'))) @@ -613,11 +816,105 @@ def printPass(self, name): def envScopeGuard(self): return EnvScopeGuard(self) + def killEnvWithSegFault(self): + if self.currEnv and Defaults.print_verbose_information_on_failure: + try: + verboseInfo = {} + # It is not safe to get the information before dispose, Redis might be stack and will not reply. + # It will cause us to hand here forever. We will only get the information after dispose, this should be + # enough as we kill Redis with segfualt which means that it should provide use with all the required details. + self.stopEnvWithSegFault() + verboseInfo['after_dispose'] = self.currEnv.getInformationAfterDispose() + self.currEnv.debugPrint(json.dumps(verboseInfo, indent=2).replace('\\n', '\n'), force=True) + except Exception as e: + print('Failed %s' % str(e)) + else: + self.stopEnvWithSegFault() + + # return number of tests done, and if all passed + def run_single_test(self, test, on_timeout_func): + done = 0 + with TestTimeLimit(self.args.test_timeout, on_timeout_func) as timeout_handler: + with self.envScopeGuard(): + if test.is_class: + test.initialize() + + Defaults.curr_test_name = test.name + try: + # 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) + return 0 + + except Exception as e: + self.printException(e) + self.addFailure(test.name + " [__init__]") + return 0 + + failures = 0 + before = getattr(obj, 'setUp', lambda x=None: None) + after = getattr(obj, 'tearDown', lambda x=None: None) + for subtest in test.get_functions(obj): + timeout_handler.reset() + failures += self._runTest(subtest, prefix='\t', + numberOfAssertionFailed=failures, + before=before, after=after) + done += 1 + + else: + failures = self._runTest(test) + done += 1 + + verboseInfo = {} + if failures > 0 and Defaults.print_verbose_information_on_failure: + lastEnv = self.currEnv + verboseInfo['before_dispose'] = lastEnv.getInformationBeforeDispose() + + # here the env is down so lets collect more info and print it + if failures > 0 and Defaults.print_verbose_information_on_failure: + verboseInfo['after_dispose'] = lastEnv.getInformationAfterDispose() + lastEnv.debugPrint(json.dumps(verboseInfo, indent=2).replace('\\n', '\n'), force=True) + return done + + def print_failures(self): + for group, failures in self.testsFailed.items(): + print('\t' + Colors.Bold(group)) + if not failures: + print('\t\t' + Colors.Bred('Exception raised during test execution. See logs')) + for failure in failures: + print('\t\t' + failure) + + def disable_progress_bar(self): + return self.args.no_output_catch or self.args.no_progress or not sys.stdout.isatty() + + def progressbar(self, num_elements): + bar = None + if not self.disable_progress_bar(): + bar = ProgressBar(max_value=num_elements, redirect_stdout=True) + for i in range(num_elements): + bar.update(i) + yield i + bar.update(num_elements) + else: + yield from range(num_elements) + def execute(self): Env.RTestInstance = self if self.args.env_only: Defaults.verbose = 2 - env = Env(testName='manual test env') + # env = Env(testName='manual test env') + env = Defaults.env_factory(testName='manual test env') if self.args.interactive_debugger: while env.isUp(): time.sleep(1) @@ -634,55 +931,93 @@ def execute(self): sys.exit(1) jobs = Queue() + n_jobs = 0 for test in self.loader: jobs.put(test, block=False) - + n_jobs += 1 + + def run_jobs_main_thread(jobs): + nonlocal done + bar = self.progressbar(n_jobs) + for _ in bar: + try: + test = jobs.get(timeout=0.1) + except Exception as e: + break + + def on_timeout(): + nonlocal done + try: + done += 1 + self.killEnvWithSegFault() + self.handleFailure(testFullName=test.name, testname=test.name, error_msg=Colors.Bred('Test timeout')) + self.print_failures() + finally: + # we must update the bar anyway to see output + bar.__next__() + + done += self.run_single_test(test, on_timeout) + + self.takeEnvDown(fullShutDown=True) + def run_jobs(jobs, results, port): Defaults.port = port - done = 0 while True: try: test = jobs.get(timeout=0.1) except Exception as e: break - with self.envScopeGuard(): - if test.is_class: - test.initialize() - - Defaults.curr_test_name = test.name + # Reset per-test: addFailure() in this worker writes into this + # dict, which is shipped as-is. The coordinator owns the + # cumulative testsFailed; the worker keeps no state across tests. + self.testsFailed = {} + output = io.StringIO() + with redirect_stdout(output): + def on_timeout(): try: - obj = test.create_instance() - - except unittest.SkipTest: - self.printSkip(test.name) - continue - + self.killEnvWithSegFault() + self.handleFailure(testFullName=test.name, testname=test.name, error_msg=Colors.Bred('Test timeout')) except Exception as e: - self.printException(e) - self.addFailure(test.name + " [__init__]") - continue - - failures = 0 - before = getattr(obj, 'setUp', None) - after = getattr(obj, 'tearDown', None) - for subtest in test.get_functions(obj): - failures += self._runTest(subtest, prefix='\t', - numberOfAssertionFailed=failures, - before=before, after=after) - done += 1 - - else: - self._runTest(test) - done += 1 + self.handleFailure(testFullName=test.name, testname=test.name, error_msg=Colors.Bred('Exception on timeout function %s' % str(e))) + finally: + # The watcher thread calls os._exit(1) right after + # this returns, bypassing Python finalization and + # the normal post-loop shutdown put. Ship both the + # per-test result and the shutdown sentinel here so + # the coordinator's bounded count of + # n_jobs + parallelism remains accurate. close() + + # join_thread() flushes both puts to the pipe first. + results.put({'test_name': test.name, 'output': output.getvalue(), + 'done': 1, 'failures': self.testsFailed, + 'shutdown': False}, block=False) + results.put({'test_name': '', 'output': '', + 'done': 0, 'failures': {}, + 'shutdown': True}, block=False) + results.close() + results.join_thread() + + done_delta = self.run_single_test(test, on_timeout) + + results.put({'test_name': test.name, 'output': output.getvalue(), + 'done': done_delta, 'failures': self.testsFailed, + 'shutdown': False}, block=False) + + # Always ship one shutdown message per worker so the coordinator + # reads a known total of n_jobs + parallelism messages. Captures + # failures raised during final shutdown (e.g. "redis did not exit + # cleanly" when env_reuse=True). + self.testsFailed = {} self.takeEnvDown(fullShutDown=True) - - # serialized the results back - results.put({'done': done, 'failures': self.testsFailed}, block=False) + results.put({'test_name': '', 'output': '', + 'done': 0, 'failures': self.testsFailed, + 'shutdown': True}, block=False) results = Queue() + # Open group for all tests at the start (parallel execution) + self._openGitHubActionsTestsGroup() if self.parallelism == 1: - run_jobs(jobs, results, Defaults.port) + run_jobs_main_thread(jobs) else : processes = [] currPort = Defaults.port @@ -691,35 +1026,65 @@ def run_jobs(jobs, results, port): currPort += 30 # safe distance for cluster and replicas processes.append(p) p.start() - + # Workers send exactly n_jobs per-test messages plus one shutdown + # message each, for a known total. The single shared queue does + # not preserve per-worker ordering, so a fast worker's shutdown + # may arrive before a slow worker's last test. We read every + # message in one bounded loop and tick the progressbar only on + # per-test ones. The has_live_processor guard turns a worker + # crash before it ships its shutdown message into a clean error + # instead of an indefinite hang. + def _get_result(): + while True: + try: + return results.get(timeout=1) + except Exception: + if not any(p.is_alive() for p in processes): + raise Exception('Failed to get job result and no more processors are alive') + + bar_iter = iter(self.progressbar(n_jobs)) + for _ in range(n_jobs + self.parallelism): + res = _get_result() + if res['output']: + print('%s' % res['output'], end="") + done += res['done'] + self.testsFailed.update(res['failures']) + if not res['shutdown']: + next(bar_iter, None) + next(bar_iter, None) # finalize bar.update(n_jobs) + for p in processes: p.join() - # join results - while True: - try: - res = results.get(timeout=0.1) - except Exception as e: - break - done += res['done'] - self.testsFailed.extend(res['failures']) - endTime = time.time() - print(Colors.Bold('Test Took: %d sec' % (endTime - startTime))) - print(Colors.Bold('Total Tests Run: %d, Total Tests Failed: %d, Total Tests Passed: %d' % (done, self.getTotalFailureCount(), done - self.getTotalFailureCount()))) + # Close group after all tests complete (parallel execution) + self._closeGitHubActionsTestsGroup() + + # Summary goes outside the group + print(Colors.Bold('\nTest Took: %d sec' % (endTime - startTime))) + print(Colors.Bold('Total Tests Run: %d, Total Tests Failed: %d, Total Tests Passed: %d' % (done, self.getFailedTestsCount(), done - self.getFailedTestsCount()))) if self.testsFailed: + if self.args.failed_tests_file: + with open(self.args.failed_tests_file, 'w') as file: + for test in self.testsFailed.keys(): + file.write(test.split(' ')[0] + "\n") + print(Colors.Bold('Failed Tests Summary:')) - for group, failures in self.testsFailed: - print('\t' + Colors.Bold(group)) - if not failures: - print('\t\t' + Colors.Bred('Exception raised during test execution. See logs')) - for failure in failures: - print('\t\t' + failure) + self.print_failures() sys.exit(1) + else: + if self.args.failed_tests_file: + with open(self.args.failed_tests_file, 'w') as file: + pass def main(): + # Avoid "UnicodeEncodeError: 'ascii' codec can't encode character" errors + sys.stdout = io.open(sys.stdout.fileno(), 'w', encoding='utf8') + sys.stderr = io.open(sys.stderr.fileno(), 'w', encoding='utf8') + # Set multiprocessing start method to fork, we have unserializable objects in the env + set_start_method('fork') RLTest().execute() diff --git a/RLTest/_version.py b/RLTest/_version.py index fca61b7d..fedc397c 100644 --- a/RLTest/_version.py +++ b/RLTest/_version.py @@ -1,8 +1,15 @@ # This attribute is the only one place that the version number is written down, # so there is only one place to change it when the version number changes. -import pkg_resources try: - __version__ = pkg_resources.get_distribution('RLTest').version -except (pkg_resources.DistributionNotFound, AttributeError): + from importlib.metadata import version +except ImportError: + try: # For Python<3.8 + from importlib_metadata import version # type: ignore + except ImportError: + version = None + +try: + __version__ = version('RLTest') +except Exception: __version__ = "99.99.99" # like redis modules diff --git a/RLTest/debuggers.py b/RLTest/debuggers.py index 36836d82..54e252cb 100644 --- a/RLTest/debuggers.py +++ b/RLTest/debuggers.py @@ -23,9 +23,9 @@ def generate_command(self, logfile=None): if '--errors-for-leak-kinds=definite' not in self.options: cmd += ['--errors-for-leak-kinds=definite'] if self.suppressions: - cmd += ['--suppressions=' + self.suppressions] + cmd += ['--suppressions=' + os.path.abspath(self.suppressions)] if logfile: - cmd += ['--log-file=' + logfile] + cmd += ['--log-file=' + os.path.abspath(logfile)] return cmd diff --git a/RLTest/env.py b/RLTest/env.py index 40906570..eb630ccc 100644 --- a/RLTest/env.py +++ b/RLTest/env.py @@ -4,11 +4,9 @@ import contextlib import inspect import os -import sys import unittest import warnings -from .Enterprise import EnterpriseClusterEnv from .exists_redis import ExistsRedisEnv from .redis_cluster import ClusterEnv from .redis_enterprise_cluster import EnterpriseRedisClusterEnv @@ -29,15 +27,16 @@ def method(*argc, **nargs): class Query: - def __init__(self, env, *query): + def __init__(self, env: 'Env', *query, **options): self.query = query + self.options = options self.env = env self.errorRaised = False self._evaluate() def _evaluate(self): try: - self.res = self.env.cmd(*self.query) + self.res = self.env.cmd(*self.query, **self.options) except Exception as e: self.res = str(e) self.errorRaised = True @@ -59,40 +58,48 @@ def debugPrint(self): self.env.debugPrint('query: %s, result: %s' % (self.query, self.res), force=True) return self - def equal(self, expected): - self.env.assertEqual(self.res, expected, 1) + def apply(self, fn): + self.res = fn(self.res) return self - def noEqual(self, expected): - self.env.assertNotEqual(self.res, expected, 1) + def map(self, fn): + self.res = list(map(fn, self.res)) return self - def true(self): - self.env.assertTrue(self.res, 1) + def equal(self, expected, depth=0, message=None): + self.env.assertEqual(self.res, expected, 1 + depth, message=message) return self - def false(self): - self.env.assertFalse(self.res, 1) + def noEqual(self, expected, depth=0, message=None): + self.env.assertNotEqual(self.res, expected, 1 + depth, message=message) return self - def ok(self): - self.env.assertEqual(self.res, 'OK', 1) + def true(self, depth=0, message=None): + self.env.assertTrue(self.res, 1 + depth, message=message) return self - def contains(self, val): - self.env.assertContains(val, self.res, 1) + def false(self, depth=0, message=None): + self.env.assertFalse(self.res, 1 + depth, message=message) return self - def notContains(self, val): - self.env.assertNotContains(val, self.res, 1) + def ok(self, depth=0, message=None): + self.env.assertEqual(self.res, 'OK', 1 + depth, message=message) return self - def error(self): - self.env.assertTrue(self.errorRaised, 1) + def contains(self, val, depth=0, message=None): + self.env.assertContains(val, self.res, 1 + depth, message=message) return self - def noError(self): - self.env.assertFalse(self.errorRaised, 1) + def notContains(self, val, depth=0, message=None): + self.env.assertNotContains(val, self.res, 1 + depth, message=message) + return self + + def error(self, depth=0, message=None): + self.env.assertTrue(self.errorRaised, 1 + depth, message=message) + return self + + def noError(self, depth=0, message=None): + self.env.assertFalse(self.errorRaised, 1 + depth, message=message) return self raiseError = genDeprecated('raiseError', error) @@ -104,6 +111,7 @@ class Defaults: module_args = None env = 'oss' + env_factory = lambda *args, **kwargs: Env(*args, **kwargs) binary = 'redis-server' proxy_binary = None re_binary = None @@ -115,13 +123,18 @@ class Defaults: tls_cert_file = None tls_key_file = None tls_ca_cert_file = None + tls_passphrase = None debugger = None + sanitizer = None debug_print = False debug_pause = False no_capture_output = False + print_verbose_information_on_failure = False + no_log = False exit_on_failure = False verbose = 0 logdir = None + loglevel = None use_slaves = False num_shards = 1 external_addr = 'localhost:6379' @@ -129,25 +142,42 @@ class Defaults: randomize_ports = False oss_password = None cluster_node_timeout = None + cluster_start_timeout = 40 curr_test_name = None - port=6379 + port = 6379 + enable_debug_command = False + enable_protected_configs = False + enable_module_command = False + terminate_retries = None + terminate_retry_secs = None + protocol = 2 + redis_config_file = None + dualTLS = False + startup_grace_secs = 0.1 def getKwargs(self): kwargs = { 'modulePath': self.module, 'moduleArgs': self.module_args, + 'port': self.port, 'useSlaves': self.use_slaves, 'useAof': self.use_aof, 'useRdbPreamble': self.use_rdb_preamble, 'dbDirPath': self.logdir, 'debugger': self.debugger, + 'sanitizer': self.sanitizer, 'noCatch': self.no_capture_output, + 'noLog': self.no_log, 'verbose': self.verbose, 'useTLS': self.use_TLS, 'tlsCertFile': self.tls_cert_file, 'tlsKeyFile': self.tls_key_file, 'tlsCaCertFile': self.tls_ca_cert_file, - 'password': self.oss_password + 'tlsPassphrase': self.tls_passphrase, + 'password': self.oss_password, + 'terminateRetries': self.terminate_retries, + 'terminateRetrySecs': self.terminate_retry_secs, + 'redisConfigFile': self.redis_config_file, } return kwargs @@ -155,7 +185,8 @@ def getKwargs(self): class Env: RTestInstance = None EnvCompareParams = ['module', 'moduleArgs', 'env', 'useSlaves', 'shardsCount', 'useAof', - 'useRdbPreamble', 'forceTcp'] + 'useRdbPreamble', 'forceTcp', 'enableDebugCommand', 'enableProtectedConfigs', + 'enableModuleCommand', 'protocol', 'password'] def compareEnvs(self, env): if env is None: @@ -166,11 +197,13 @@ def compareEnvs(self, env): return True def __init__(self, testName=None, testDescription=None, module=None, - moduleArgs=None, env=None, useSlaves=None, shardsCount=None, decodeResponses=None, + moduleArgs=None, env=None, useSlaves=None, shardsCount=None, decodeResponses=None, password=None, useAof=None, useRdbPreamble=None, forceTcp=False, useTLS=False, tlsCertFile=None, tlsKeyFile=None, - tlsCaCertFile=None, logDir=None, redisBinaryPath=None, dmcBinaryPath=None, + tlsCaCertFile=None, tlsPassphrase=None, logDir=None, redisBinaryPath=None, dmcBinaryPath=None, redisEnterpriseBinaryPath=None, noDefaultModuleArgs=False, clusterNodeTimeout = None, - freshEnv=False): + freshEnv=False, enableDebugCommand=None, enableModuleCommand=None, enableProtectedConfigs=None, protocol=None, + terminateRetries=None, terminateRetrySecs=None, redisConfigFile=None, dualTLS=False, + startupGraceSecs=None): self.testName = testName if testName else Defaults.curr_test_name if self.testName is None: @@ -194,21 +227,39 @@ def __init__(self, testName=None, testDescription=None, module=None, self.verbose = Defaults.verbose self.logDir = logDir if logDir else Defaults.logdir self.forceTcp = forceTcp + self.password = password self.debugger = Defaults.debugger + self.sanitizer = Defaults.sanitizer self.useTLS = useTLS if useTLS else Defaults.use_TLS self.tlsCertFile = tlsCertFile if tlsCertFile else Defaults.tls_cert_file self.tlsKeyFile = tlsKeyFile if tlsKeyFile else Defaults.tls_key_file self.tlsCaCertFile = tlsCaCertFile if tlsCaCertFile else Defaults.tls_ca_cert_file + self.tlsPassphrase = tlsPassphrase if tlsPassphrase else Defaults.tls_passphrase self.redisBinaryPath = expandBinary(redisBinaryPath) if redisBinaryPath else Defaults.binary self.dmcBinaryPath = expandBinary(dmcBinaryPath) if dmcBinaryPath else Defaults.proxy_binary self.redisEnterpriseBinaryPath = expandBinary(redisEnterpriseBinaryPath) if redisEnterpriseBinaryPath else Defaults.re_binary self.clusterNodeTimeout = clusterNodeTimeout if clusterNodeTimeout else Defaults.cluster_node_timeout self.port = Defaults.port + self.enableDebugCommand = enableDebugCommand if enableDebugCommand is not None else Defaults.enable_debug_command + self.enableProtectedConfigs = enableProtectedConfigs if enableProtectedConfigs is not None\ + else Defaults.enable_protected_configs + self.enableModuleCommand = enableModuleCommand if enableModuleCommand is not None else Defaults.enable_module_command + + self.terminateRetries = terminateRetries + self.terminateRetrySecs = terminateRetrySecs + + self.protocol = protocol if protocol is not None else Defaults.protocol + + self.redisConfigFile = redisConfigFile if redisConfigFile is not None else Defaults.redis_config_file self.assertionFailedSummary = [] - if (not freshEnv) and Env.RTestInstance and Env.RTestInstance.currEnv and self.compareEnvs(Env.RTestInstance.currEnv): + self.dualTLS = dualTLS if dualTLS else Defaults.dualTLS + + self.startupGraceSecs = startupGraceSecs if startupGraceSecs is not None else Defaults.startup_grace_secs + + if not freshEnv and Env.RTestInstance and Env.RTestInstance.currEnv and self.compareEnvs(Env.RTestInstance.currEnv): self.envRunner = Env.RTestInstance.currEnv.envRunner else: if Env.RTestInstance and Env.RTestInstance.currEnv: @@ -231,6 +282,16 @@ def __init__(self, testName=None, testDescription=None, module=None, if Defaults.debug_pause: input('\tenv is up, attach to any process with gdb and press any button to continue.') + def getInformationBeforeDispose(self): + return { + "env": self.env, + "test": self.testName, + "env_info": self.envRunner.getInformationBeforeDispose() + } + + def getInformationAfterDispose(self): + return self.envRunner.getInformationAfterDispose() + def getEnvByName(self): verbose = False kwargs = self.getEnvKwargs() @@ -240,7 +301,7 @@ def getEnvByName(self): if self.env == 'oss': kwargs.update(single_args) - kwargs['password'] = Defaults.oss_password + kwargs['password'] = Defaults.oss_password if self.password is None else self.password return StandardEnv(redisBinaryPath=self.redisBinaryPath, outputFilesFormat='%s-' + '%s-oss' % test_fname, **kwargs) @@ -258,7 +319,8 @@ def getEnvByName(self): dmcBinaryPath=Defaults.proxy_binary, **kwargs) if self.env == 'oss-cluster': - kwargs['password'] = Defaults.oss_password + kwargs['password'] = Defaults.oss_password if self.password is None else self.password + kwargs['clusterStartTimeout'] = Defaults.cluster_start_timeout return ClusterEnv(shardsCount=self.shardsCount, redisBinaryPath=self.redisBinaryPath, outputFilesFormat='%s-' + '%s-oss-cluster' % test_fname, randomizePorts=Defaults.randomize_ports, @@ -293,15 +355,28 @@ def getEnvKwargs(self): 'useAof': self.useAof, 'useRdbPreamble': self.useRdbPreamble, 'dbDirPath': self.logDir, + 'loglevel': Defaults.loglevel, 'debugger': Defaults.debugger, + 'sanitizer': Defaults.sanitizer, 'noCatch': Defaults.no_capture_output, + 'noLog': Defaults.no_log, 'verbose': Defaults.verbose, 'useTLS': self.useTLS, 'tlsCertFile': self.tlsCertFile, 'tlsKeyFile': self.tlsKeyFile, 'tlsCaCertFile': self.tlsCaCertFile, 'clusterNodeTimeout': self.clusterNodeTimeout, - 'port': self.port + 'tlsPassphrase': self.tlsPassphrase, + 'port': self.port, + 'enableDebugCommand': self.enableDebugCommand, + 'enableProtectedConfigs': self.enableProtectedConfigs, + 'enableModuleCommand': self.enableModuleCommand, + 'protocol': self.protocol, + 'terminateRetries': self.terminateRetries, + 'terminateRetrySecs': self.terminateRetrySecs, + 'redisConfigFile': self.redisConfigFile, + 'dualTLS': self.dualTLS, + 'startupGraceSecs': self.startupGraceSecs, } return kwargs @@ -312,6 +387,9 @@ def start(self, masters = True, slaves = True ): def stop(self, masters = True, slaves = True): self.envRunner.stopEnv(masters, slaves) + def stopEnvWithSegFault(self, masters = True, slaves = True): + self.envRunner.stopEnvWithSegFault(masters, slaves) + def getEnvStr(self): return self.env @@ -326,6 +404,19 @@ def getClusterConnectionIfNeeded(self): else: return self.getConnection() + def waitCluster(self, timeout_sec=40): + if isinstance(self.envRunner, (ClusterEnv, EnterpriseRedisClusterEnv)): + self.envRunner.waitCluster(timeout_sec) + + def addShardToClusterIfExists(self): + if isinstance(self.envRunner, ClusterEnv): + test_fname = self.testName.replace(':', '_') + output_files_format = '%s-' + '%s-oss-cluster' % test_fname + kwargs = self.getEnvKwargs() + return self.envRunner.addShardToCluster(self.redisBinaryPath, output_files_format, **kwargs) + else: + raise Exception("env is not an oss-cluster") + def getSlaveConnection(self): return self.envRunner.getSlaveConnection() @@ -395,41 +486,41 @@ def assertTrue(self, val, depth=0, message=None): def assertFalse(self, val, depth=0, message=None): self.assertEqual(bool(val), False, depth + 1, message=message) - def assertContains(self, value, holder, depth=0): - self._assertion('%s should contain %s' % (repr(holder), repr(value)), value in holder, depth) + def assertContains(self, value, holder, depth=0, message=None): + self._assertion('%s should contain %s' % (repr(holder), repr(value)), value in holder, depth, message=message) - def assertNotContains(self, value, holder, depth=0): - self._assertion('%s should not contain %s' % (repr(holder), repr(value)), value not in holder, depth) + def assertNotContains(self, value, holder, depth=0, message=None): + self._assertion('%s should not contain %s' % (repr(holder), repr(value)), value not in holder, depth, message=message) - def assertGreaterEqual(self, value1, value2, depth=0): - self._assertion('%s >= %s' % (repr(value1), repr(value2)), value1 >= value2, depth) + def assertGreaterEqual(self, value1, value2, depth=0, message=None): + self._assertion('%s >= %s' % (repr(value1), repr(value2)), value1 >= value2, depth, message=message) - def assertGreater(self, value1, value2, depth=0): - self._assertion('%s > %s' % (repr(value1), repr(value2)), value1 > value2, depth) + def assertGreater(self, value1, value2, depth=0, message=None): + self._assertion('%s > %s' % (repr(value1), repr(value2)), value1 > value2, depth, message=message) - def assertLessEqual(self, value1, value2, depth=0): - self._assertion('%s <= %s' % (repr(value1), repr(value2)), value1 <= value2, depth) + def assertLessEqual(self, value1, value2, depth=0, message=None): + self._assertion('%s <= %s' % (repr(value1), repr(value2)), value1 <= value2, depth, message=message) - def assertLess(self, value1, value2, depth=0): - self._assertion('%s < %s' % (repr(value1), repr(value2)), value1 < value2, depth) + def assertLess(self, value1, value2, depth=0, message=None): + self._assertion('%s < %s' % (repr(value1), repr(value2)), value1 < value2, depth, message=message) - def assertIsNotNone(self, value, depth=0): - self._assertion('%s is not None' % (repr(value)), value is not None, depth) + def assertIsNotNone(self, value, depth=0, message=None): + self._assertion('%s is not None' % (repr(value)), value is not None, depth, message=message) - def assertIsNone(self, value, depth=0): - self._assertion('%s is None' % (repr(value)), value is None, depth) + def assertIsNone(self, value, depth=0, message=None): + self._assertion('%s is None' % (repr(value)), value is None, depth, message=message) - def assertIsInstance(self, value, instance, depth=0): - self._assertion('%s instance of %s' % (repr(value), repr(instance)), isinstance(value, instance), depth) + def assertIsInstance(self, value, instance, depth=0, message=None): + self._assertion('%s instance of %s' % (repr(value), repr(instance)), isinstance(value, instance), depth, message=message) - def assertAlmostEqual(self, value1, value2, delta, depth=0): - self._assertion('%s almost equels %s (delta %s)' % (repr(value1), repr(value2), repr(delta)), abs(value1 - value2) <= delta, depth) + def assertAlmostEqual(self, value1, value2, delta, depth=0, message=None): + self._assertion('%s almost equels %s (delta %s)' % (repr(value1), repr(value2), repr(delta)), abs(value1 - value2) <= delta, depth, message) - def expect(self, *query): - return Query(self, *query) + def expect(self, *query, **options): + return Query(self, *query, **options) - def cmd(self, *query): - res = self.con.execute_command(*query) + def cmd(self, *query, **options): + res = self.con.execute_command(*query, **options) self.debugPrint('query: %s, result: %s' % (repr(query), repr(res))) return res @@ -442,11 +533,11 @@ def exists(self, val): def assertExists(self, val, depth=0): warnings.warn("AssertExists is deprecated, use cmd instead", DeprecationWarning) - self._assertion('%s exists in db' % repr(val), self.con.exists(val), depth=0) + self._assertion('%s exists in db' % repr(val), self.con.exists(val), depth=depth) - def executeCommand(self, *query): + def executeCommand(self, *query, **options): warnings.warn("execute_command is deprecated, use cmd instead", DeprecationWarning) - return self.cmd(*query) + return self.cmd(*query, **options) def reloadingIterator(self): yield 1 @@ -485,10 +576,10 @@ def assertResponseError(self, msg=None, contained=None): yield 1 except Exception as e: if contained: - self.assertContains(contained, str(e), depth=2) - self._assertion('Expected Response Error', True, depth=1) + self.assertContains(contained, str(e), depth=2, message=msg) + self._assertion('Expected Response Error', True, depth=1, message=msg) else: - self._assertion('Expected Response Error', False, depth=1) + self._assertion('Expected Response Error', False, depth=1, message=msg) def restartAndReload(self, shardId=None, timeout_sec=40): self.dumpAndReload(restart=True, shardId=shardId, timeout_sec=timeout_sec) 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/exists_redis.py b/RLTest/exists_redis.py index deb381f5..af02155c 100644 --- a/RLTest/exists_redis.py +++ b/RLTest/exists_redis.py @@ -1,3 +1,4 @@ + from __future__ import print_function import redis import subprocess @@ -16,6 +17,7 @@ def __init__(self, addr='localhost:6379', password = None, **kwargs): self.host, self.port = addr.split(':') self.port = int(self.port) self.password = password + self.useTLS = kwargs['useTLS'] self.decodeResponses = kwargs.get('decodeResponses', False) @property @@ -64,7 +66,7 @@ def _waitForBgsaveToFinish(self): while True: if not self.getConnection().execute_command('info', 'Persistence')['rdb_bgsave_in_progress']: break - + def flush(self): self.getConnection().flushall() self._waitForBgsaveToFinish() @@ -101,6 +103,9 @@ def checkExitCode(self): def isUp(self): return self.getConnection().ping() + def isTLS(self): + return self.useTLS + def exists(self, val): return self.getConnection().exists(val) diff --git a/RLTest/loader.py b/RLTest/loader.py index ec87617b..40995ded 100644 --- a/RLTest/loader.py +++ b/RLTest/loader.py @@ -1,23 +1,30 @@ from __future__ import print_function import os import sys -import imp +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_file = open(self.filename) - module = imp.load_module(self.modulename, module_file, self.filename, ('.py', 'r', imp.PY_SOURCE)) + module_spec = importlib.util.spec_from_file_location(self.modulename, self.filename) + module = importlib.util.module_from_spec(module_spec) + sys.modules[self.modulename] = module + module_spec.loader.exec_module(module) obj = getattr(module, self.symbol) self.target = obj @@ -27,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 @@ -41,16 +50,19 @@ 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_file = open(self.filename) - module = imp.load_module(self.modulename, module_file, self.filename, ('.py', 'r', imp.PY_SOURCE)) + module_spec = importlib.util.spec_from_file_location(self.modulename, self.filename) + module = importlib.util.module_from_spec(module_spec) + sys.modules[self.modulename] = module + module_spec.loader.exec_module(module) obj = getattr(module, self.symbol) self.clsname = obj.__name__ self.cls = obj @@ -65,17 +77,22 @@ 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 class TestLoader(object): - def __init__(self, filter=None): + def __init__(self): self.tests = [] - self.toplevel_filter = filter - self.subfilter = None def load_spec(self, arg): + # if arg is a list, load its elements + if isinstance(arg, list): + for spec in arg: + self.load_spec(spec) + return + # See what kind of spec this is! """ Load tests from single argument form, e.g. foo.py:BarBaz @@ -96,30 +113,45 @@ def load_spec(self, arg): sys.path.append(dirname) module_name, _ = os.path.splitext(os.path.basename(filename)) + toplevel_filter, subfilter = None, None if varname: if '.' in varname: - self.toplevel_filter, self.subfilter = varname.split('.') + toplevel_filter, subfilter = varname.split('.') else: - self.toplevel_filter = varname + toplevel_filter = varname - self.load_files(dirname, module_name) + self.load_files(dirname, module_name, toplevel_filter, subfilter) - def load_files(self, module_dir, module_name): + def load_files(self, module_dir, module_name, toplevel_filter=None, subfilter=None): filename = '%s/%s.py' % (module_dir, module_name) - module_file = open(filename, 'r') - module = imp.load_module(module_name, module_file, filename, - ('.py', 'r', imp.PY_SOURCE)) - for symbol in dir(module): - if not self.filter_modulevar(symbol): - continue - - obj = getattr(module, symbol) - if inspect.isclass(obj): - methnames = [mname for mname in dir(obj) - if self.filter_method(mname)] - self.tests.append(TestClass(filename, symbol, module_name, methnames)) - elif inspect.isfunction(obj): - self.tests.append(TestFunction(filename, symbol, module_name)) + try: + module_spec = importlib.util.spec_from_file_location(module_name, filename) + module = importlib.util.module_from_spec(module_spec) + sys.modules[module_name] = module + module_spec.loader.exec_module(module) + for symbol in dir(module): + if not self.filter_modulevar(symbol, toplevel_filter): + continue + + obj = getattr(module, symbol) + if inspect.isclass(obj): + methnames = [mname for mname in dir(obj) + if self.filter_method(mname, subfilter)] + spec = resolve_spec(obj) + self.tests.append( + TestClass(filename, symbol, module_name, methnames, env_spec=spec) + ) + elif inspect.isfunction(obj): + 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 + except Exception as e: + print(Colors.Red("Problems in file %s: %s" % (filename, e))) + raise e def scan_dir(self, testdir): for filename in os.listdir(testdir): @@ -127,17 +159,17 @@ def scan_dir(self, testdir): module_name, ext = os.path.splitext(filename) self.load_files(testdir, module_name) - def filter_modulevar(self, candidate): + def filter_modulevar(self, candidate, toplevel_filter): if not candidate.lower().startswith('test'): return False - if self.toplevel_filter and candidate != self.toplevel_filter: + if toplevel_filter and candidate != toplevel_filter: return False return True - def filter_method(self, candidate): + def filter_method(self, candidate, subfilter): if not candidate.lower().startswith('test'): return False - if self.subfilter and candidate != self.subfilter: + if subfilter and candidate != subfilter: return False return True @@ -146,12 +178,11 @@ def __iter__(self): return iter(self.tests) def print_tests(self): + tests = [] for t in self.tests: - print("Test: ", t.name) if t.is_class: - print("\tClass") - print("\tFunctions") for m in t.functions: - print("\t\t", m) + tests.append(f"{t.name}.{m}") else: - print("\tFunction") \ No newline at end of file + tests.append(t.name) + print(*sorted(tests), sep='\n') diff --git a/RLTest/redis_cluster.py b/RLTest/redis_cluster.py index d24d3c9e..ab2e975e 100644 --- a/RLTest/redis_cluster.py +++ b/RLTest/redis_cluster.py @@ -1,13 +1,14 @@ from __future__ import print_function -from rediscluster.connection import SSLClusterConnection, ClusterConnectionPool - from .redis_std import StandardEnv -import rediscluster +from redis.cluster import ClusterNode import redis import time from RLTest.utils import Colors +# Interval in seconds between status updates during cluster wait +CLUSTER_STATUS_INTERVAL_SEC = 5 + class ClusterEnv(object): def __init__(self, **kwargs): @@ -21,6 +22,12 @@ def __init__(self, **kwargs): useSlaves = kwargs.get('useSlaves', False) self.useTLS = kwargs['useTLS'] self.decodeResponses = kwargs.get('decodeResponses', False) + self.tlsPassphrase = kwargs.get('tlsPassphrase', None) + self.protocol = kwargs.get('protocol', 2) + self.terminateRetries = kwargs.get('terminateRetries', None) + self.terminateRetrySecs = kwargs.get('terminateRetrySecs', None) + self.verbose = kwargs.get('verbose', False) + self.clusterStartTimeout = kwargs.pop('clusterStartTimeout', 40) startPort = kwargs.pop('port', 10000) totalRedises = self.shardsCount * (2 if useSlaves else 1) randomizePorts = kwargs.pop('randomizePorts', False) @@ -42,30 +49,75 @@ def printEnvData(self, prefix=''): print(Colors.Yellow(prefix + 'shard: %d' % (i + 1))) shard.printEnvData(prefix + '\t') - def waitCluster(self, timeout_sec=40): + def getInformationBeforeDispose(self): + return [shard.getInformationBeforeDispose() for shard in self.shards] - st = time.time() + def getInformationAfterDispose(self): + return [shard.getInformationAfterDispose() for shard in self.shards] + + def _countOk(self): + """Returns count of shards reporting cluster_state:ok""" ok = 0 + for shard in self.shards: + con = shard.getConnection() + try: + status = con.execute_command('CLUSTER', 'INFO') + except Exception as e: + print('got error on cluster info, will try again, %s' % str(e)) + continue + if 'cluster_state:ok' in str(status): + ok += 1 + return ok + + def _countAgreeSlots(self): + """Returns count of shards that agree on slots view""" + ok = 0 + first_view = None + for shard in self.shards: + con = shard.getConnection() + try: + slots_view = con.execute_command('CLUSTER', 'SLOTS') + except Exception as e: + print('got error on cluster slots, will try again, %s' % str(e)) + continue + if first_view is None: + first_view = slots_view + if slots_view == first_view: + ok += 1 + return ok + + def waitCluster(self, timeout_sec=40, verbose=True): + st = time.time() + last_status_time = st + total_shards = len(self.shards) + + if verbose: + print(Colors.Yellow('Waiting for cluster to be ready (timeout: %d seconds, %d shards)...' % + (timeout_sec, total_shards))) while st + timeout_sec > time.time(): - ok = 0 - for shard in self.shards: - con = shard.getConnection() - status = con.execute_command('CLUSTER', 'INFO') - if 'cluster_state:ok' in str(status): - ok += 1 - if ok == len(self.shards): + ok_count = self._countOk() + slots_count = self._countAgreeSlots() + + if ok_count == total_shards and slots_count == total_shards: + elapsed = time.time() - st + if verbose: + print(Colors.Green('Cluster is ready after %.1f seconds' % elapsed)) for shard in self.shards: - try: - shard.getConnection().execute_command('FT.CLUSTERREFRESH') - except Exception: - pass try: shard.getConnection().execute_command('SEARCH.CLUSTERREFRESH') except Exception: pass return + # Print periodic status update + now = time.time() + if verbose and (now - last_status_time) >= CLUSTER_STATUS_INTERVAL_SEC: + elapsed = now - st + print(Colors.Yellow(' Cluster wait: %.1fs elapsed - %d/%d shards OK, %d/%d agree on slots...' % + (elapsed, ok_count, total_shards, slots_count, total_shards))) + last_status_time = now + time.sleep(0.1) raise RuntimeError( "Cluster OK wait loop timed out after %s seconds" % timeout_sec) @@ -73,14 +125,25 @@ def waitCluster(self, timeout_sec=40): def startEnv(self, masters=True, slaves=True): if self.envIsUp == True: return # env is already up + + total_shards = len(self.shards) + if self.verbose: + print(Colors.Yellow('Starting cluster with %d shards...' % total_shards)) + try: - for shard in self.shards: + for i, shard in enumerate(self.shards): shard.startEnv(masters, slaves) - except Exception: + if self.verbose: + print(Colors.Yellow(' Started shard %d/%d' % (i + 1, total_shards))) + except Exception as e: + print(Colors.Bred('Error starting shard %d: %s' % (i + 1, str(e)))) + print(Colors.Bred('Stopping all shards...')) for shard in self.shards: shard.stopEnv() raise + if self.verbose: + print(Colors.Yellow('Configuring cluster topology...')) slots_per_node = int(16384 / len(self.shards)) + 1 for i, shard in enumerate(self.shards): con = shard.getConnection() @@ -96,13 +159,22 @@ def startEnv(self, masters=True, slaves=True): try: con.execute_command('CLUSTER', 'ADDSLOTS', *(str(x) for x in range(start_slot, end_slot))) - except Exception: - pass + except Exception as e: + print(Colors.Bred(' Error assigning slots %d-%d to shard %d: %s' % + (start_slot, end_slot - 1, i + 1, str(e)))) - self.waitCluster() + if self.verbose: + print(Colors.Yellow(' Configured shard %d/%d (slots %d-%d)' % + (i + 1, total_shards, start_slot, min(end_slot - 1, 16383)))) + + self.waitCluster(timeout_sec=self.clusterStartTimeout, verbose=self.verbose) self.envIsUp = True self.envIsHealthy = True + def stopEnvWithSegFault(self, masters=True, slaves=True): + for shard in self.shards: + shard.stopEnvWithSegFault(masters, slaves) + def stopEnv(self, masters=True, slaves=True): self.envIsUp = False self.envIsHealthy = False @@ -115,29 +187,27 @@ def getConnection(self, shardId=1): return self.shards[shardId - 1].getConnection() def getClusterConnection(self): + statupNode = [ClusterNode(a['host'], a['port']) for a in self.getMasterNodesList()] if self.useTLS: - # workaround for error on - # got an unexpected keyword argument 'ssl' - # we enforce the connection_class instead of setting ssl=True - pool = ClusterConnectionPool( - startup_nodes=self.getMasterNodesList(), - connection_class=SSLClusterConnection, - ssl_cert_reqs=None, + return redis.RedisCluster( + ssl=True, ssl_keyfile=self.shards[0].getTLSKeyFile(), ssl_certfile=self.shards[0].getTLSCertFile(), + ssl_cert_reqs=None, ssl_ca_certs=self.shards[0].getTLSCACertFile(), - ) - if pool.connection_kwargs: - pool.connection_kwargs.pop('ssl', None) - return rediscluster.RedisCluster( - startup_nodes=self.getMasterNodesList(), - connection_pool=pool, - decode_responses=self.decodeResponses + ssl_password=self.tlsPassphrase, + password=self.password, + startup_nodes=statupNode, + decode_responses=self.decodeResponses, + protocol=self.protocol, + terminateRetries=self.terminateRetries, terminateRetrySecs=self.terminateRetrySecs ) else: - return rediscluster.RedisCluster( - startup_nodes=self.getMasterNodesList(), - decode_responses=self.decodeResponses, password=self.password) + return redis.RedisCluster( + startup_nodes=statupNode, + decode_responses=self.decodeResponses, password=self.password, + protocol=self.protocol, + terminateRetries=self.terminateRetries, terminateRetrySecs=self.terminateRetrySecs) def getSlaveConnection(self): raise Exception('unsupported') @@ -159,26 +229,27 @@ def getOSSMasterNodesConnectionList(self): # Gets a cluster connection by key. On std redis the default connection is returned. def getConnectionByKey(self, key, command): - if self.useTLS: - # workaround for error on - # got an unexpected keyword argument 'ssl' - # we enforce the connection_class instead of setting ssl=True - pool = ClusterConnectionPool( - startup_nodes=self.getMasterNodesList(), - connection_class=SSLClusterConnection, - ssl_cert_reqs=None, - ssl_keyfile=self.shards[0].getTLSKeyFile(), - ssl_certfile=self.shards[0].getTLSCertFile(), - ssl_ca_certs=self.shards[0].getTLSCACertFile(), - ) - if pool.connection_kwargs: - pool.connection_kwargs.pop('ssl', None) - else: - pool = ClusterConnectionPool( - startup_nodes=self.getMasterNodesList() - ) - con = pool.get_connection_by_key(key, command) - return redis.StrictRedis(host=con.host, port=con.port, decode_responses=self.decodeResponses, password=self.password) + clusterConn = self.getClusterConnection() + target_node = clusterConn._determine_nodes(command, key) # we will always which will give us the node responsible for the key + return clusterConn.get_redis_connection(target_node[0]) + + def addShardToCluster(self, redisBinaryPath, output_files_format, **kwargs): + kwargs.pop('port') + port = self.shards[-1].port + 2 # use a fresh port + self.shardsCount += 1 + new_shard = StandardEnv(redisBinaryPath, port, outputFilesFormat=output_files_format, + serverId=self.shardsCount, clusterEnabled=True, **kwargs) + try: + new_shard.startEnv() + except Exception: + new_shard.stopEnv() + raise + self.shards.append(new_shard) + # Notify other shards that the new shard is available and wait for the topology change to be acknowledged. + conn = new_shard.getConnection() + for s in self.shards: + conn.execute_command('CLUSTER', 'MEET', '127.0.0.1', s.getMasterPort()) + self.waitCluster() def flush(self): self.getClusterConnection().flushall() @@ -203,7 +274,7 @@ def checkExitCode(self): return True def isUp(self): - return self.envIsUp or self.waitCluster() + return self.envIsUp or self.envIsHealthy and self.waitCluster() def isHealthy(self): return self.envIsHealthy diff --git a/RLTest/redis_enterprise_cluster.py b/RLTest/redis_enterprise_cluster.py index 34935dbe..7edaa5dd 100644 --- a/RLTest/redis_enterprise_cluster.py +++ b/RLTest/redis_enterprise_cluster.py @@ -1,4 +1,3 @@ -import rediscluster from redis import StrictRedis from .exists_redis import ExistsRedisEnv diff --git a/RLTest/redis_std.py b/RLTest/redis_std.py index b70c9d09..6ad00659 100644 --- a/RLTest/redis_std.py +++ b/RLTest/redis_std.py @@ -7,7 +7,7 @@ import uuid import platform import psutil - +import signal import redis from .random_port import get_random_port @@ -20,8 +20,11 @@ class StandardEnv(object): def __init__(self, redisBinaryPath, port=6379, modulePath=None, moduleArgs=None, outputFilesFormat=None, dbDirPath=None, useSlaves=False, serverId=1, password=None, libPath=None, clusterEnabled=False, decodeResponses=False, - useAof=False, useRdbPreamble=True, debugger=None, noCatch=False, unix=False, verbose=False, useTLS=False, tlsCertFile=None, - tlsKeyFile=None, tlsCaCertFile=None, clusterNodeTimeout = None): + useAof=False, useRdbPreamble=True, debugger=None, sanitizer=None, noCatch=False, noLog=False, unix=False, verbose=False, useTLS=False, + tlsCertFile=None, tlsKeyFile=None, tlsCaCertFile=None, clusterNodeTimeout=None, tlsPassphrase=None, enableDebugCommand=False, protocol=2, + terminateRetries=None, terminateRetrySecs=None, enableProtectedConfigs=False, enableModuleCommand=False, loglevel=None, + redisConfigFile=None, dualTLS=False, startupGraceSecs=0.1 + ): self.uuid = uuid.uuid4().hex self.redisBinaryPath = os.path.expanduser(redisBinaryPath) if redisBinaryPath.startswith( '~/') else redisBinaryPath @@ -38,13 +41,20 @@ def __init__(self, redisBinaryPath, port=6379, modulePath=None, moduleArgs=None, self.useRdbPreamble = useRdbPreamble self.envIsUp = False self.debugger = debugger + self.sanitizer = sanitizer self.noCatch = noCatch + self.noLog = noLog + self.loglevel = loglevel self.environ = os.environ.copy() self.useUnix = unix self.dbDirPath = dbDirPath self.masterProcess = None + self.masterStdout = None + self.masterStderr = None self.masterExitCode = None self.slaveProcess = None + self.slaveStdout = None + self.slaveStderr = None self.slaveExitCode = None self.verbose = verbose self.role = MASTER @@ -53,6 +63,16 @@ def __init__(self, redisBinaryPath, port=6379, modulePath=None, moduleArgs=None, self.tlsKeyFile = tlsKeyFile self.tlsCaCertFile = tlsCaCertFile self.clusterNodeTimeout = clusterNodeTimeout + self.tlsPassphrase = tlsPassphrase + self.enableDebugCommand = enableDebugCommand + self.enableModuleCommand = enableModuleCommand + self.enableProtectedConfigs = enableProtectedConfigs + self.protocol = protocol + self.terminateRetries = terminateRetries + self.terminateRetrySecs = terminateRetrySecs + self.redisConfigFile = redisConfigFile + self.dualTLS = dualTLS + self.startupGraceSecs = startupGraceSecs if port > 0: self.port = port @@ -64,14 +84,14 @@ def __init__(self, redisBinaryPath, port=6379, modulePath=None, moduleArgs=None, self.port = -1 self.slavePort = -1 + if self.has_interactive_debugger and serverId > 1: + assert self.noCatch and not self.useSlaves and not self.clusterEnabled + if self.useUnix: if self.clusterEnabled: raise ValueError('Unix sockets cannot be used with cluster mode') self.port = -1 - if self.has_interactive_debugger and serverId > 1: - assert self.noCatch and not self.useSlaves and not self.clusterEnabled - if self.useTLS: if self.useUnix: raise ValueError('Unix sockets cannot be used with TLS enabled mode') @@ -102,9 +122,11 @@ def __init__(self, redisBinaryPath, port=6379, modulePath=None, moduleArgs=None, self.environ['LD_LIBRARY_PATH'] = self.libPath self.masterCmdArgs = self.createCmdArgs(MASTER) + self.masterOSEnv = self.createCmdOSEnv(MASTER) if self.useSlaves: self.slaveServerId = serverId + 1 self.slaveCmdArgs = self.createCmdArgs(SLAVE) + self.slaveOSEnv = self.createCmdOSEnv(SLAVE) self.envIsHealthy = True @@ -138,15 +160,36 @@ def getTLSCACertFile(self): def has_interactive_debugger(self): return self.debugger and self.debugger.is_interactive + def _getRedisVersion(self): + options = { + 'stderr': subprocess.PIPE, + 'stdin': subprocess.PIPE, + 'stdout': subprocess.PIPE, + } + p = subprocess.Popen(args=[self.redisBinaryPath, '--version'], **options) + while p.poll() is None: + time.sleep(0.1) + exit_code = p.poll() + if exit_code != 0: + raise Exception('Could not extract Redis version') + out, err = p.communicate() + out = out.decode('utf-8') + v = out[out.find("v=") + 2:out.find("sha=") - 1].split('.') + return int(v[0]) * 10000 + int(v[1]) * 100 + int(v[2]) + def createCmdArgs(self, role): cmdArgs = [] if self.debugger: cmdArgs += self.debugger.generate_command(self._getValgrindFilePath(role) if not self.noCatch else None) cmdArgs += [self.redisBinaryPath] + + if self.redisConfigFile: + cmdArgs += [self.redisConfigFile] + if self.port > -1: if self.useTLS: - cmdArgs += ['--port', str(0), '--tls-port', str(self.getPort(role))] + cmdArgs += ['--port', str(self.getPort(role) + 1500) if self.dualTLS else str(0), '--tls-port', str(self.getPort(role))] else: cmdArgs += ['--port', str(self.getPort(role))] else: @@ -170,10 +213,12 @@ def createCmdArgs(self, role): args += arg.split(' ') cmdArgs += args - if self.dbDirPath is not None: - cmdArgs += ['--dir', self.dbDirPath] - if self.outputFilesFormat is not None and not self.noCatch: + if self.noLog: + cmdArgs += ['--logfile', '/dev/null'] + elif self.outputFilesFormat is not None and not self.noCatch: cmdArgs += ['--logfile', self._getFileName(role, '.log')] + if self.loglevel is not None: + cmdArgs += ['--loglevel', self.loglevel] if self.outputFilesFormat is not None: cmdArgs += ['--dbfilename', self._getFileName(role, '.rdb')] if role == SLAVE: @@ -197,11 +242,31 @@ def createCmdArgs(self, role): cmdArgs += ['--tls-cert-file', self.getTLSCertFile()] cmdArgs += ['--tls-key-file', self.getTLSKeyFile()] cmdArgs += ['--tls-ca-cert-file', self.getTLSCACertFile()] - + if self.tlsPassphrase: + cmdArgs += ['--tls-key-file-pass', self.tlsPassphrase] + + cmdArgs += ['--tls-replication', 'yes'] + + if self._getRedisVersion() > 70000: + if self.enableDebugCommand: + cmdArgs += ['--enable-debug-command', 'yes'] + if self.enableProtectedConfigs: + cmdArgs += ['--enable-protected-configs', 'yes'] + if self.enableModuleCommand: + cmdArgs += ['--enable-module-command', 'yes'] return cmdArgs - def waitForRedisToStart(self, con): - wait_for_conn(con, retries=1000 if self.debugger else 200) + def createCmdOSEnv(self, role): + if self.sanitizer != 'addr' and self.sanitizer != 'address': + return self.environ + osenv = self.environ.copy() + san_log = self._getFileName(role, '.asan.log') + asan_options = osenv.get("ASAN_OPTIONS") + osenv["ASAN_OPTIONS"] = "{OPT}:log_path={DIR}".format(OPT=asan_options, DIR=san_log) + return osenv + + def waitForRedisToStart(self, con, proc): + wait_for_conn(con, proc, retries=1000 if self.debugger else 200) self._waitForAOFChild(con) def getPid(self, role): @@ -245,11 +310,59 @@ def printEnvData(self, prefix=''): print(Colors.Yellow(prefix + 'slave:')) self._printEnvData(prefix + '\t', SLAVE) + def getInformationBeforeDispose(self): + res = {} + instances = [(MASTER, self.getConnection(), self.masterProcess)] + if self.useSlaves: + instances.append((SLAVE, self.getSlaveConnection(), self.slaveProcess)) + for role, conn, proc in instances: + info = None + try: + info = conn.execute_command('info', 'everything') + except redis.exceptions.RedisError: + pass + res[role] = { + 'info': info + } + return res + + def getInformationAfterDispose(self): + res = {} + instances = [(MASTER, self.masterStdout, self.masterStderr)] + if self.useSlaves: + instances.append((SLAVE, self.slaveStdout, self.slaveStderr)) + for role, stdout, stderr in instances: + stdoutStr = None + stderrStr = None + logs = None + try: + stdoutStr = stdout.read().decode('utf8') + except (NameError, AttributeError): + pass + + try: + stderrStr = stderr.read().decode('utf8') + except (NameError, AttributeError): + pass + + try: + with open(os.path.join(self.dbDirPath, self._getFileName(role, '.log'))) as f: + logs = f.read() + except FileNotFoundError: + pass + + res[role] = { + 'stdout': stdoutStr, + 'stderr': stderrStr, + 'logs': logs, + } + return res + def startEnv(self, masters = True, slaves = True): if self.envIsUp and self.envIsHealthy: return # env is already up stdoutPipe = subprocess.PIPE - stderrPipe = subprocess.STDOUT + stderrPipe = subprocess.PIPE stdinPipe = subprocess.PIPE if self.noCatch: stdoutPipe = sys.stdout @@ -262,33 +375,74 @@ def startEnv(self, masters = True, slaves = True): 'stderr': stderrPipe, 'stdin': stdinPipe, 'stdout': stdoutPipe, - 'env': self.environ } if self.verbose: print(Colors.Green("Redis master command: " + ' '.join(self.masterCmdArgs))) if masters and self.masterProcess is None: - self.masterProcess = subprocess.Popen(args=self.masterCmdArgs, **options) - con = self.getConnection() - self.waitForRedisToStart(con) + self.masterProcess = subprocess.Popen(args=self.masterCmdArgs, env=self.masterOSEnv, cwd=self.dbDirPath, + **options) + time.sleep(self.startupGraceSecs) + if self._isAlive(self.masterProcess): + con = self.getConnection() + self.waitForRedisToStart(con, self.masterProcess) + else: + self.masterProcess = None if self.useSlaves and slaves and self.slaveProcess is None: if self.verbose: print(Colors.Green("Redis slave command: " + ' '.join(self.slaveCmdArgs))) - self.slaveProcess = subprocess.Popen(args=self.slaveCmdArgs, **options) - con = self.getSlaveConnection() - self.waitForRedisToStart(con) - self.envIsUp = True + self.slaveProcess = subprocess.Popen(args=self.slaveCmdArgs, env=self.slaveOSEnv, cwd=self.dbDirPath, + **options) + time.sleep(self.startupGraceSecs) + if self._isAlive(self.slaveProcess): + con = self.getSlaveConnection() + self.waitForRedisToStart(con, self.slaveProcess) + else: + self.slaveProcess = None + + self.envIsUp = self.masterProcess is not None or self.slaveProcess is not None self.envIsHealthy = self.masterProcess is not None and (self.slaveProcess is not None if self.useSlaves else True) + # self.masterStdout = self.masterProcess.stdout if self.masterProcess else None + # self.masterStderr = self.masterProcess.stderr if self.masterProcess else None + + # if self.slaveProcess is not None: + # self.slaveStdout = self.slaveProcess.stdout if self.slaveProcess else None + # self.slaveStderr = self.slaveProcess.stderr if self.slaveProcess else None + def _isAlive(self, process): if not process: return False - # Check if child process has terminated. Set and return returncode - # attribute + # check if child process has terminated if process.poll() is None: return True return False + def _segfault(self, role, retries=3): + process = self.masterProcess if role == MASTER else self.slaveProcess + if not self._isAlive(process): + return + for _ in range(retries): + if process.poll() is None: # None returns if the processes is not finished yet, retry until redis exits + time.sleep(1) + process.send_signal(signal.SIGSEGV) + else: + return + print(Colors.Bred('Failed killing processes with sigsegv, forcely kill the processes.')) + for _ in range(retries): + if process.poll() is None: # None returns if the processes is not finished yet, retry until redis exits + time.sleep(1) + process.kill() + else: + return + print(Colors.Bred('Failed killing processes with sigkill.')) + + def stopEnvWithSegFault(self, masters = True, slaves = True): + if self.masterProcess is not None and masters is True: + self._segfault(MASTER) + if self.useSlaves and self.slaveProcess is not None and slaves is True: + self._segfault(SLAVE) + def _stopProcess(self, role): process = self.masterProcess if role == MASTER else self.slaveProcess serverId = self.masterServerId if role == MASTER else self.slaveServerId @@ -311,12 +465,31 @@ def _stopProcess(self, role): p.wait() except: pass - while True: + + if self.terminateRetries is None: + # ask once, then wait for process to exit process.terminate() - if process.poll() is None: # None returns if the processes is not finished yet, retry until redis exits + termination_start_time = time.time() + while process.poll() is None: # None returns if the processes is not finished yet, retry until redis exits time.sleep(0.1) - else: - break + if time.time() - termination_start_time > 30: + # if process is still running after 30 seconds, try reading its output + process_out, process_err = process.communicate() + print(Colors.Bred(f'\t[TERMINATING] out ({process_out}), error ({process_err})')) + else: + # keep asking every few seconds until process has exited, otherwise kill + if self.terminateRetrySecs is None: + self.terminateRetrySecs = 1 + done = False + for i in range(0, self.terminateRetries): + process.terminate() + if process.poll() is None: # None returns if the processes is not finished yet, retry until redis exits + time.sleep(self.terminateRetrySecs) + else: + done = True + break + if not done: + process.kill() if role == MASTER: self.masterExitCode = process.poll() @@ -353,24 +526,25 @@ def stopEnv(self, masters = True, slaves = True): self.envIsUp = self.masterProcess is not None or self.slaveProcess is not None self.envIsHealthy = self.masterProcess is not None and (self.slaveProcess is not None if self.useSlaves else True) - def _getConnection(self, role): if self.useUnix: return redis.StrictRedis(unix_socket_path=self.getUnixPath(role), - password=self.password, decode_responses=self.decodeResponses) + password=self.password, decode_responses=self.decodeResponses, protocol=self.protocol) elif self.useTLS: return redis.StrictRedis('localhost', self.getPort(role), password=self.password, ssl=True, + ssl_password=self.tlsPassphrase, ssl_keyfile=self.getTLSKeyFile(), ssl_certfile=self.getTLSCertFile(), ssl_cert_reqs=None, ssl_ca_certs=self.getTLSCACertFile(), - decode_responses=self.decodeResponses + decode_responses=self.decodeResponses, + protocol=self.protocol ) else: return redis.StrictRedis('localhost', self.getPort(role), - password=self.password, decode_responses=self.decodeResponses) + password=self.password, decode_responses=self.decodeResponses, protocol=self.protocol) def getConnection(self, shardId=1): return self._getConnection(MASTER) @@ -473,3 +647,7 @@ def hmset(self, *args): def keys(self, reg): return self.getConnection().keys(reg) + + def setTerminateRetries(self, retries=3, seconds=1): + self.terminateRetries = retries + self.terminateRetrySecs = seconds diff --git a/RLTest/utils.py b/RLTest/utils.py index e5f106fb..9ed3f8da 100644 --- a/RLTest/utils.py +++ b/RLTest/utils.py @@ -6,10 +6,18 @@ import redis import itertools -def wait_for_conn(conn, retries=20, command='PING', shouldBe=True): + +def is_github_actions(): + """Check if running in GitHub Actions environment""" + return os.getenv('GITHUB_ACTIONS', '') != '' + + +def wait_for_conn(conn, proc, retries=20, command='PING', shouldBe=True): """Wait until a given Redis connection is ready""" err1 = '' while retries > 0: + if proc.poll() is not None: + raise Exception(f'Redis server is dead (pid={proc.pid})') try: if conn.execute_command(command) == shouldBe: return conn @@ -44,17 +52,17 @@ def Yellow(data): def Bold(data): return '\033[1m' + data + '\033[0m' + @staticmethod + def Red(data): + return '\033[31m' + data + '\033[0m' + @staticmethod def Bred(data): return '\033[31;1m' + data + '\033[0m' @staticmethod def Gray(data): - return '\033[30;1m' + data + '\033[0m' - - @staticmethod - def Lgray(data): - return '\033[30;47m' + data + '\033[0m' + return '\033[90;1m' + data + '\033[0m' @staticmethod def Blue(data): @@ -66,10 +74,13 @@ def Green(data): def fix_modules(modules, defaultModules=None): # modules is one of the following: - # None - # ['path',...] - if modules: - if not isinstance(modules, list): + # None - take the default modules + # ['path',...] - load module(s) from given path(s) + # Empty list - return None, meaning don't load any module. + if modules is not None: + if len(modules) == 0: + return None + elif not isinstance(modules, list): modules = [modules] modules = list(map(lambda p: os.path.abspath(p), modules)) else: @@ -81,12 +92,40 @@ def split_by_semicolon(s): def args_list_to_dict(args_list): def dicty(args): - return dict((seq.split(' ')[0], seq) for seq in args) + return {seq.split(' ')[0].upper(): seq for seq in args} return list(map(lambda args: dicty(args), args_list)) def join_lists(lists): return list(itertools.chain.from_iterable(lists)) +def _merge_by_words(explicit_str, defaultArgs): + """Merge a plain explicit arg string with defaults using word-level key matching. + For each default arg, if its key doesn't appear as a word in the explicit string, + append the entire default arg to the string. + Returns the merged string wrapped as [[merged_string]]. + """ + if not defaultArgs or not defaultArgs[0]: + return [[explicit_str]] + explicit_words_upper = [w.upper() for w in explicit_str.split()] + merged = explicit_str + for arg in defaultArgs[0]: + key = arg.split()[0].upper() + if key not in explicit_words_upper: + merged += ' ' + arg + return [[merged.strip()]] + +def _merge_by_dict(modulesArgs, defaultArgs): + """Merge structured (already-split) modulesArgs with defaults using dict-based key matching. + For each module, any default key not present in the explicit args is appended. + """ + modules_args_dict = args_list_to_dict(modulesArgs) + for imod, args_list in enumerate(defaultArgs): + for arg in args_list: + name = arg.split(' ')[0].upper() + if name not in modules_args_dict[imod]: + modulesArgs[imod] += [arg] + return modulesArgs + def fix_modulesArgs(modules, modulesArgs, defaultArgs=None, haveSeqs=True): # modulesArgs is one of the following: # None @@ -94,18 +133,24 @@ def fix_modulesArgs(modules, modulesArgs, defaultArgs=None, haveSeqs=True): # ['args ...', ...]: arg list for a single module # [['arg', ...', ...], ...]: arg strings for multiple modules - # arg string is a string of words seperated by whitespace - # arg string can be seperated by semicolons into (logical) arg lists. - # semicolons can be escaped with a backslash. - # arg list is a list of arg strings. - # arg list starts with an arg name that can later be used for argument overriding. - # arg strings are transformed into arg lists (haveSeqs parameter controls this behavior): - # thus, 'num 1; names a b' becomes ['num 1', 'names a b'] + # For a plain string without semicolons: + # If defaultArgs exist, merge by checking if each default key appears as + # a word in the explicit string. Missing defaults are appended. + # If no defaultArgs, keep the string as-is (no splitting needed). + # For strings with semicolons, split by semicolons and use dict-based merge. + # For list inputs, use dict-based merge. + + is_plain_str = False # tracks if input was a plain string without semicolons if type(modulesArgs) == str: - # case # 'args ...': arg string for a single module - # transformed into [['arg', ...]] - modulesArgs = [split_by_semicolon(modulesArgs)] + parts = split_by_semicolon(modulesArgs) + if len(parts) == 1: + # No semicolons - keep as plain string + is_plain_str = True + modulesArgs = [[modulesArgs.strip()]] + else: + # Has semicolons - already split + modulesArgs = [parts] elif type(modulesArgs) == list: args = [] is_list = False @@ -113,7 +158,6 @@ def fix_modulesArgs(modules, modulesArgs, defaultArgs=None, haveSeqs=True): for argx in modulesArgs: if type(argx) == list: # case [['arg', ...], ...]: arg strings for multiple modules - # already transformed into [['arg', ...], ...] if is_str: print(Colors.Bred('Error in args: %s' % str(modulesArgs))) sys.exit(1) @@ -125,7 +169,6 @@ def fix_modulesArgs(modules, modulesArgs, defaultArgs=None, haveSeqs=True): args += [argx] else: # case ['args ...', ...]: arg list for a single module - # transformed into [['arg', ...], ...] if is_list: print(Colors.Bred('Error in args: %s' % str(modulesArgs))) sys.exit(1) @@ -158,19 +201,13 @@ def fix_modulesArgs(modules, modulesArgs, defaultArgs=None, haveSeqs=True): return modulesArgs # if there are fewer defaultArgs than modulesArgs, we should bail out - # as we cannot pad the defaults with emply arg lists if defaultArgs and len(modulesArgs) > len(defaultArgs): print(Colors.Bred('Number of module args sets in Env does not match number of modules')) print(defaultArgs) print(modulesArgs) sys.exit(1) - # for each module, sync defaultArgs to modulesARgs - modules_args_dict = args_list_to_dict(modulesArgs) - for imod, args_list in enumerate(defaultArgs): - for arg in args_list: - name = arg.split(' ')[0] - if name not in modules_args_dict[imod]: - modulesArgs[imod] += [arg] - - return modulesArgs + if is_plain_str: + return _merge_by_words(modulesArgs[0][0], defaultArgs) + else: + return _merge_by_dict(modulesArgs, defaultArgs) diff --git a/RLTest2.sh b/RLTest2.sh deleted file mode 100755 index 660aa123..00000000 --- a/RLTest2.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash - -set -e - -show_if_error() { - [[ $SHOW == 1 ]] && echo "${@:1}" - if [[ $VERBOSE == 1 ]]; then - { "${@:1}"; } - else - { "${@:1}"; } > /tmp/rltest.log 2>&1 - [ $? != 0 ] && cat /tmp/rltest.log - rm -f /tmp/rltest.log - fi -} - -install_prerequisites() { - [[ -f .installed2 ]] && return - show_if_error apt-get -qq update - show_if_error apt-get -q install -y python2 python-psutil - show_if_error apt-get -q install -y curl ca-certificates - show_if_error curl -s https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py - show_if_error python2 /tmp/get-pip.py - show_if_error pip install virtualenv - touch .installed2 -} - -is_installed() { - [[ $({ dpkg -l "$*" >/dev/null 2>&1 ; echo $? ;}) == 0 ]] && echo yes -} - -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" - -install_prerequisites -if [[ -d venv2 ]]; then - . $HERE/venv2/bin/activate -else - show_if_error python2 -m virtualenv --system-site-packages venv2 - . $HERE/venv2/bin/activate - show_if_error pip install -r $HERE/requirements.txt -fi -PYTHONPATH=$HERE/RLTest python2 -m RLTest "$@" diff --git a/RLTest3.sh b/RLTest3.sh deleted file mode 100755 index 61f46974..00000000 --- a/RLTest3.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash - -set -e - -show_if_error() { - [[ $SHOW == 1 ]] && echo "${@:1}" - if [[ $VERBOSE == 1 ]]; then - { "${@:1}"; } - else - { "${@:1}"; } > /tmp/rltest.log 2>&1 - [ $? != 0 ] && cat /tmp/rltest.log - rm -f /tmp/rltest.log - fi -} - -install_prerequisites() { - [[ -f .installed3 ]] && return - show_if_error apt-get -qq update - show_if_error apt-get -q install -y python3 python3-distutils python3-psutil - show_if_error apt-get -q install -y curl ca-certificates - show_if_error curl -s https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py - show_if_error python3 /tmp/get-pip.py - show_if_error pip install virtualenv - touch .installed3 -} - -is_installed() { - [[ $({ dpkg -l "$*" >/dev/null 2>&1 ; echo $? ;}) == 0 ]] && echo yes -} - -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" - -install_prerequisites -if [[ -d venv3 ]]; then - . $HERE/venv3/bin/activate -else - show_if_error python3 -m virtualenv --system-site-packages venv3 - . $HERE/venv3/bin/activate - show_if_error pip install -r $HERE/requirements.txt -fi -PYTHONPATH=$HERE/RLTest python3 -m RLTest "$@" diff --git a/config.txt b/config.txt index 76866d4f..8dbe6cc0 100644 --- a/config.txt +++ b/config.txt @@ -1,3 +1,5 @@ #-vv --clear-logs +--enable-debug-command +--enable-protected-configs #--debug diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 00000000..e537ed1d --- /dev/null +++ b/poetry.lock @@ -0,0 +1,388 @@ +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "async-timeout" +version = "4.0.3" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_full_version < \"3.11.3\"" +files = [ + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, +] + +[package.dependencies] +typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.2.7" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, + {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, + {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, + {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, + {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, + {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, + {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, + {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, + {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, + {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, + {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, + {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, + {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, + {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, + {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, + {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, + {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, + {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "distro" +version = "1.9.0" +description = "Distro - an OS platform information API" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, + {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "importlib-metadata" +version = "6.7.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version < \"3.8\"" +files = [ + {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, + {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, +] + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-perf (>=0.9.2)", "pytest-ruff"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "packaging" +version = "24.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, +] + +[[package]] +name = "pluggy" +version = "1.2.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, + {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "progressbar2" +version = "4.2.0" +description = "A Python Progressbar library to provide visual (yet text based) progress to long running operations." +optional = false +python-versions = ">=3.7.0" +groups = ["main"] +files = [ + {file = "progressbar2-4.2.0-py2.py3-none-any.whl", hash = "sha256:1a8e201211f99a85df55f720b3b6da7fb5c8cdef56792c4547205be2de5ea606"}, + {file = "progressbar2-4.2.0.tar.gz", hash = "sha256:1393922fcb64598944ad457569fbeb4b3ac189ef50b5adb9cef3284e87e394ce"}, +] + +[package.dependencies] +python-utils = ">=3.0.0" + +[package.extras] +docs = ["sphinx (>=1.8.5)"] +tests = ["flake8 (>=3.7.7)", "freezegun (>=0.3.11)", "pytest (>=4.6.9)", "pytest-cov (>=2.6.1)", "pytest-mypy", "sphinx (>=1.8.5)"] + +[[package]] +name = "psutil" +version = "7.2.2" +description = "Cross-platform lib for process and system monitoring." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"}, + {file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"}, + {file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"}, + {file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"}, + {file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"}, + {file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"}, + {file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"}, + {file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"}, + {file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"}, + {file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"}, + {file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"}, + {file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"}, + {file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"}, + {file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"}, + {file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"}, + {file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"}, + {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"}, + {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"}, + {file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"}, + {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"}, + {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"}, +] + +[package.extras] +dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] +test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] + +[[package]] +name = "pytest" +version = "7.4.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "python-utils" +version = "3.5.2" +description = "Python Utils is a module with some convenient utilities not included with the standard Python install" +optional = false +python-versions = ">3.6.0" +groups = ["main"] +files = [ + {file = "python-utils-3.5.2.tar.gz", hash = "sha256:68198854fc276bc4b2403b261703c218e01ef564dcb072a7096ed9ea7aa5130c"}, + {file = "python_utils-3.5.2-py2.py3-none-any.whl", hash = "sha256:8bfefc3430f1c48408fa0e5958eee51d39840a5a987c2181a579e99ab6fe5ca6"}, +] + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +docs = ["mock", "python-utils", "sphinx"] +loguru = ["loguru"] +tests = ["flake8", "loguru", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mypy", "sphinx", "types-setuptools"] + +[[package]] +name = "redis" +version = "5.0.8" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "redis-5.0.8-py3-none-any.whl", hash = "sha256:56134ee08ea909106090934adc36f65c9bcbbaecea5b21ba704ba6fb561f8eb4"}, + {file = "redis-5.0.8.tar.gz", hash = "sha256:0c5b10d387568dfe0698c6fad6615750c24170e548ca2deac10c649d463e9870"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} +importlib-metadata = {version = ">=1.0", markers = "python_version < \"3.8\""} +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +hiredis = ["hiredis (>1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_full_version <= \"3.11.0a6\"" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.7.1" +description = "Backported and Experimental Type Hints for Python 3.7+" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, + {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, +] + +[[package]] +name = "zipp" +version = "3.15.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version < \"3.8\"" +files = [ + {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, + {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8 ; python_version < \"3.12\"", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\""] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.7.0" +content-hash = "4c0bf04c282d813c354cec134acde585db43ea3ba340affbe35a966a8ac3a6b3" diff --git a/pyproject.toml b/pyproject.toml index 0ae11512..89c548e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,8 @@ [tool.poetry] name = "RLTest" -version = "0.4.2" -description="Redis Labs Test Framework, allow to run tests on redis and modules on a variety of environments" -authors = ["RedisLabs "] +version = "99.99.99" +description="Redis Modules Test Framework, allow to run tests on redis and modules on a variety of environments" +authors = ["Redis, Inc. "] license = "BSD-3-Clause" readme = "README.md" @@ -14,22 +14,26 @@ classifiers = [ 'Topic :: Database', 'Programming Language :: Python', 'Intended Audience :: Developers', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', 'License :: OSI Approved :: BSD License', 'Development Status :: 5 - Production/Stable' ] [tool.poetry.dependencies] -python = "^2.7,<2.8 || >= 3.5.0" +python = ">=3.7.0" distro = "^1.5.0" -redis = "^3.5.3" -redis-py-cluster = "*" -psutil = "5.8.0" # 5.9.0 currently broken on macOS -pytest-cov = "2.5" +redis = ">=5.0.0" +psutil = "^7.2.2" +pytest-cov = "^4.1.0" +pytest = "^7.4" +progressbar2 = "4.2" [tool.poetry.urls] repository = "https://github.com/RedisLabsModules/RLTest" @@ -37,14 +41,6 @@ repository = "https://github.com/RedisLabsModules/RLTest" [tool.poetry.scripts] RLTest = 'RLTest.__main__:main' -[tool.poetry.dev-dependencies] -codecov = "*" -flake8 = "*" -rmtest = "^0.7.0" -nose = "^1.3.7" -ml2rt = "^0.2.0" -pytest = "4.6" - [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/tests/flow/modules/Makefile b/tests/flow/modules/Makefile index 551f2df4..2bf9af19 100644 --- a/tests/flow/modules/Makefile +++ b/tests/flow/modules/Makefile @@ -5,20 +5,21 @@ uname_S := $(shell sh -c 'uname -s 2>/dev/null || echo not') ifeq ($(uname_S),Linux) SHOBJ_CFLAGS ?= -fno-common -g -ggdb SHOBJ_LDFLAGS ?= -shared -Bsymbolic + CC=gcc else SHOBJ_CFLAGS ?= -dynamic -fno-common -g -ggdb - SHOBJ_LDFLAGS ?= -bundle -undefined dynamic_lookup + SHOBJ_LDFLAGS ?= -dynamiclib -undefined dynamic_lookup + CC=clang endif -CFLAGS = -I$(RM_INCLUDE_DIR) -Wall -g -fPIC -lc -lm -std=gnu99 -CC=gcc +CFLAGS = -I$(RM_INCLUDE_DIR) -Wall -g -fPIC -std=gnu99 all: module1.so module2.so module1.so: module1.o - $(LD) -o $@ module1.o $(SHOBJ_LDFLAGS) $(LIBS) -lc + $(CC) -o $@ module1.o $(SHOBJ_LDFLAGS) $(LIBS) module2.so: module2.o - $(LD) -o $@ module2.o $(SHOBJ_LDFLAGS) $(LIBS) -lc + $(CC) -o $@ module2.o $(SHOBJ_LDFLAGS) $(LIBS) clean: rm -rf *.xo *.so *.o diff --git a/tests/flow/test_modules_flow.py b/tests/flow/test_modules_flow.py index c72a27a2..bf28640f 100644 --- a/tests/flow/test_modules_flow.py +++ b/tests/flow/test_modules_flow.py @@ -2,8 +2,6 @@ from RLTest import Env -CONTAINS_MODULES = os.environ.get("CONTAINS_MODULES", None) - def checkSampleModules(env): redis_conn = env.getConnection() @@ -26,10 +24,4 @@ def test_modulesSimpleFlow(env): checkSampleModules(env) -class test_modulesWithArgs(): - def __init__(self): - if CONTAINS_MODULES is not None: - self.env = Env(moduleArgs='DUPLICATE_POLICY BLOCK') - checkSampleModules(self.env) - else: - print("Skipping test given there is no module") + diff --git a/tests/flow/test_simple_flow.py b/tests/flow/test_simple_flow.py index 3eee7872..02fbbe34 100644 --- a/tests/flow/test_simple_flow.py +++ b/tests/flow/test_simple_flow.py @@ -1,4 +1,5 @@ import redis +import os from RLTest import Env @@ -20,8 +21,7 @@ def test_getTLSConnection(env): port = node_0['port'] password = node_0['password'] try: - insecure_redis = redis.StrictRedis(host, port, - password=password) + insecure_redis = redis.StrictRedis(host, port, password=password) insecure_redis.execute_command("info") except redis.exceptions.ConnectionError as exc: # we where expecting this exception @@ -48,14 +48,40 @@ def test_skipOnAOF(env): def test_skipOnDebugger(env): env.skipOnDebugger() - def test_skipOnEnterpriseCluster(env): env.skipOnEnterpriseCluster() - def test_skipOnTcp(env): env.skipOnTcp() - def test_skipOnUnixSocket(env): env.skipOnUnixSocket() + +def test_resp3(env): + env = Env(protocol=3) + res = env.cmd('client', 'list') + env.assertTrue("resp=3" in res.decode('ascii')) + +def test_redisConfigFile(): + # create redis.conf file in /tmp + redisConfigFile = '/tmp/redis.conf' + if os.path.isfile(redisConfigFile): + os.unlink(redisConfigFile) + with open(redisConfigFile, 'w') as f: + f.write('loglevel verbose\n') + + # create env with the config file + env = Env(redisConfigFile=redisConfigFile) + res = env.cmd('config', 'get', 'loglevel') + env.assertEqual(res[1].decode('ascii'), 'verbose') + env.stop() + + # update config file and create new env + if os.path.isfile(redisConfigFile): + os.unlink(redisConfigFile) + with open(redisConfigFile, 'w') as f: + f.write('loglevel debug\n') + env = Env(redisConfigFile=redisConfigFile) + env.start() + res = env.cmd('config', 'get', 'loglevel') + env.assertEqual(res[1].decode('ascii'), 'debug') diff --git a/tests/unit/test_EnterpriseClusterEnv.py b/tests/unit/test_EnterpriseClusterEnv.py deleted file mode 100644 index 843c9cee..00000000 --- a/tests/unit/test_EnterpriseClusterEnv.py +++ /dev/null @@ -1,67 +0,0 @@ -import shutil -import tempfile -from unittest import TestCase - -from RLTest.env import Defaults -from tests.unit.test_common import DMC_PROXY_BINARY, REDIS_BINARY - - -class TestEnterpriseClusterEnv(TestCase): - - def setUp(self): - # Create a temporary directory - self.test_dir = tempfile.mkdtemp() - - def tearDown(self): - pass - # Remove the directory after the test - shutil.rmtree(self.test_dir) - - def test_preper_module(self): - pass - - def test_print_env_data(self): - pass - - def test_start_env(self): - pass - - def test_stop_env(self): - default_args = Defaults().getKwargs() - default_args['dbDirPath'] = self.test_dir - default_args['libPath'] = self.test_dir - default_args['shardsCount'] = 1 - default_args['dmcBinaryPath'] = DMC_PROXY_BINARY - default_args['redisBinaryPath'] = REDIS_BINARY - # TODO: install RE and enable this ( we need the DMC_PROXY_BINARY ) - # default_enterprise_cluster = EnterpriseClusterEnv(outputFilesFormat='%s-test',**default_args) - - def test_get_connection(self): - pass - - def test_get_slave_connection(self): - pass - - def test_flush(self): - pass - - def test_dump_and_reload(self): - pass - - def test_broadcast(self): - pass - - def test_check_exit_code(self): - pass - - def test_exists(self): - pass - - def test_hmset(self): - pass - - def test_keys(self): - pass - - def test_is_up(self): - pass diff --git a/tests/unit/test_common.py b/tests/unit/test_common.py index cbe2c04a..76e42c9b 100644 --- a/tests/unit/test_common.py +++ b/tests/unit/test_common.py @@ -2,8 +2,6 @@ from unittest import TestCase REDIS_BINARY = os.environ.get("REDIS_BINARY", "redis-server") -REDIS_ENTERPRISE_BINARY = os.environ.get("REDIS_ENTERPRISE_BINARY", None) -DMC_PROXY_BINARY = os.environ.get("DMC_PROXY_BINARY", None) TLS_CERT = os.environ.get("TLS_CERT", "./tls/redis.crt") TLS_KEY = os.environ.get("TLS_KEY", "./tls/redis.key") diff --git a/tests/unit/test_debuggers.py b/tests/unit/test_debuggers.py index 44cde202..d4c8a272 100644 --- a/tests/unit/test_debuggers.py +++ b/tests/unit/test_debuggers.py @@ -13,11 +13,13 @@ def test_generate_command_default(self): def test_generate_command_supression(self): default_valgrind = Valgrind(options="", suppressions="file") cmd_args = default_valgrind.generate_command() - assert ['valgrind', '--error-exitcode=255', '--leak-check=full', '--errors-for-leak-kinds=definite', - '--suppressions=file'] == cmd_args + assert ['valgrind', '--error-exitcode=255', '--leak-check=full', '--errors-for-leak-kinds=definite'] == cmd_args[:4] + assert '--suppressions=' in cmd_args[4] + assert 'file' in cmd_args[4] def test_generate_command_logfile(self): default_valgrind = Valgrind(options="") cmd_args = default_valgrind.generate_command('logfile') - assert ['valgrind', '--error-exitcode=255', '--leak-check=full', '--errors-for-leak-kinds=definite', - '--log-file=logfile'] == cmd_args + assert ['valgrind', '--error-exitcode=255', '--leak-check=full', '--errors-for-leak-kinds=definite'] == cmd_args[:4] + assert '--log-file=' in cmd_args[4] + assert 'logfile' in cmd_args[4] diff --git a/tests/unit/test_env.py b/tests/unit/test_env.py index 59e2674c..6788e87c 100644 --- a/tests/unit/test_env.py +++ b/tests/unit/test_env.py @@ -3,7 +3,8 @@ from unittest import TestCase from RLTest import Env -from tests.unit.test_common import REDIS_BINARY, REDIS_ENTERPRISE_BINARY, DMC_PROXY_BINARY +from RLTest.redis_cluster import ClusterEnv +from tests.unit.test_common import REDIS_BINARY class TestEnvOss(TestCase): @@ -21,8 +22,7 @@ def test_compare_envs(self): pass def test_get_env_by_name(self): - self.env = Env(useSlaves=False, env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY, - redisEnterpriseBinaryPath=REDIS_ENTERPRISE_BINARY, dmcBinaryPath=DMC_PROXY_BINARY) + self.env = Env(useSlaves=False, env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY) assert self.env.isUp() == True self.env.stop() assert self.env.isUp() == False @@ -34,21 +34,17 @@ def test_stop(self): pass def test_get_env_str(self): - self.env = Env(useSlaves=True, env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY, - redisEnterpriseBinaryPath=REDIS_ENTERPRISE_BINARY, dmcBinaryPath=DMC_PROXY_BINARY) + self.env = Env(useSlaves=True, env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY) assert self.env.getEnvStr() == 'oss' self.env.stop() assert self.env.isUp() == False def test_compare_env(self): - self.env = Env(env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY, - redisEnterpriseBinaryPath=REDIS_ENTERPRISE_BINARY, dmcBinaryPath=DMC_PROXY_BINARY) - env = Env(env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY, - redisEnterpriseBinaryPath=REDIS_ENTERPRISE_BINARY, dmcBinaryPath=DMC_PROXY_BINARY) + self.env = Env(env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY) + env = Env(env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY) assert self.env.compareEnvs(env) is True env.stop() - env = Env(env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY, - redisEnterpriseBinaryPath=REDIS_ENTERPRISE_BINARY, dmcBinaryPath=DMC_PROXY_BINARY, useAof=True) + env = Env(env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY, useAof=True) assert self.env.compareEnvs(env) is False env.stop() self.env.stop() @@ -69,14 +65,12 @@ def test_flush(self): pass def test_is_cluster(self): - self.env = Env(useSlaves=True, env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY, - redisEnterpriseBinaryPath=REDIS_ENTERPRISE_BINARY, dmcBinaryPath=DMC_PROXY_BINARY) + self.env = Env(useSlaves=True, env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY) assert self.env.isCluster() == False assert self.env.isUp() == True self.env.stop() assert self.env.isUp() == False - self.env = Env(useSlaves=True, env='oss-cluster', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY, - redisEnterpriseBinaryPath=REDIS_ENTERPRISE_BINARY, dmcBinaryPath=DMC_PROXY_BINARY) + self.env = Env(useSlaves=True, env='oss-cluster', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY) assert self.env.isCluster() == True self.env.stop() @@ -189,8 +183,7 @@ def test_check_exit_code(self): pass def test_is_up(self): - self.env = Env(useSlaves=True, env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY, - redisEnterpriseBinaryPath=REDIS_ENTERPRISE_BINARY, dmcBinaryPath=DMC_PROXY_BINARY) + self.env = Env(useSlaves=True, env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY) assert self.env.isCluster() == False assert self.env.isUp() == True self.env.stop() @@ -206,8 +199,7 @@ def test_skip_on_cluster(self): pass def test_is_unix_socket(self): - self.env = Env(useSlaves=True, env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY, - redisEnterpriseBinaryPath=REDIS_ENTERPRISE_BINARY, dmcBinaryPath=DMC_PROXY_BINARY) + self.env = Env(useSlaves=True, env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY) assert self.env.isCluster() == False assert self.env.isUp() == True assert self.env.isUnixSocket() == False @@ -215,8 +207,7 @@ def test_is_unix_socket(self): assert self.env.isUp() == False def test_is_tcp(self): - self.env = Env(useSlaves=True, env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY, - redisEnterpriseBinaryPath=REDIS_ENTERPRISE_BINARY, dmcBinaryPath=DMC_PROXY_BINARY) + self.env = Env(useSlaves=True, env='oss', logDir=self.test_dir, redisBinaryPath=REDIS_BINARY) assert self.env.isCluster() == False assert self.env.isUp() == True assert self.env.isTcp() == True @@ -231,3 +222,19 @@ def test_skip_on_unix_socket(self): def test_skip_on_enterprise_cluster(self): pass + + def test_with_password(self): + password = 'GoodPassword42' + self.env = Env(useSlaves=True, env='oss', password=password, logDir=self.test_dir, redisBinaryPath=REDIS_BINARY) + assert self.env.envRunner.getPassword() == password + conn = self.env.getConnection() + assert conn.ping() == True + self.env.stop() + assert self.env.isUp() == False + self.env = Env(useSlaves=True, env='oss-cluster', password=password, logDir=self.test_dir, redisBinaryPath=REDIS_BINARY) + assert isinstance(self.env.envRunner, ClusterEnv) + assert self.env.envRunner.password == password + conn = self.env.getConnection() + assert conn.ping() == True + self.env.stop() + assert self.env.isUp() == False 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) == () diff --git a/tests/unit/test_parallel_drain.py b/tests/unit/test_parallel_drain.py new file mode 100644 index 00000000..1270c3bd --- /dev/null +++ b/tests/unit/test_parallel_drain.py @@ -0,0 +1,86 @@ +"""Regression test for a hang in the parallel test coordinator. + +The parallel coordinator uses a single ``results`` queue carrying one message +per test. Workers push these messages while the coordinator drains them in its +progressbar loop. If the coordinator ever stops draining before workers stop +pushing, large per-test outputs saturate the pipe (~64 KiB on Linux), worker +``put()`` calls block in ``pipe_write``, and ``p.join()`` hangs indefinitely. + +This test reproduces the saturation scenario by spawning workers that each +push many large messages, then asserts that a coordinator that drains +continuously throughout the workers' lifetime finishes promptly with every +message accounted for and every worker cleanly exited. +""" + +import multiprocessing as mp +import sys +import time +from unittest import TestCase + + +# ~32 KiB per message × 8 workers × 8 messages = 2 MiB total, well over the +# typical 64 KiB pipe buffer on Linux, so writers will block on ``pipe_write`` +# unless the parent is actively reading throughout. +_PAYLOAD_BYTES = 32 * 1024 +_NUM_WORKERS = 8 +_MSGS_PER_WORKER = 8 +_JOIN_TIMEOUT_SECS = 30.0 + + +def _worker_puts_many_results(results, n_msgs, payload_bytes): + # Queue.put is async (via a feeder thread), but at process exit the feeder + # must flush the buffered items to the pipe before the worker can exit. If + # the parent is not draining, that flush blocks forever. + payload = 'x' * payload_bytes + for i in range(n_msgs): + results.put({'test_name': 't%d' % i, 'output': payload, + 'done': 1, 'failures': {}}) + + +class TestParallelResultsDrain(TestCase): + + def setUp(self): + if sys.platform == 'win32': + self.skipTest('fork start method is unavailable on Windows') + self._ctx = mp.get_context('fork') + self._procs = [] + + def tearDown(self): + # Safety net: if the test ever hangs despite the fix, make sure the + # pytest session can still exit cleanly. + for p in self._procs: + if p.is_alive(): + p.kill() + p.join(timeout=5) + + def test_continuous_drain_does_not_hang(self): + results = self._ctx.Queue() + self._procs = [ + self._ctx.Process( + target=_worker_puts_many_results, + args=(results, _MSGS_PER_WORKER, _PAYLOAD_BYTES), + ) + for _ in range(_NUM_WORKERS) + ] + for p in self._procs: + p.start() + + # Mimic the coordinator's progressbar loop: drain every per-test + # message live, in the same thread, while workers are still running. + expected = _NUM_WORKERS * _MSGS_PER_WORKER + start = time.monotonic() + collected = [results.get(timeout=_JOIN_TIMEOUT_SECS) for _ in range(expected)] + for p in self._procs: + p.join(timeout=_JOIN_TIMEOUT_SECS) + elapsed = time.monotonic() - start + + for p in self._procs: + self.assertFalse( + p.is_alive(), + 'worker still alive after join; results pipe likely saturated', + ) + self.assertEqual(p.exitcode, 0) + self.assertEqual(len(collected), expected) + # The drain should return well under its own timeout; we only assert a + # loose upper bound to avoid flakiness on slow machines. + self.assertLess(elapsed, _JOIN_TIMEOUT_SECS) diff --git a/tests/unit/test_redis_cluster.py b/tests/unit/test_redis_cluster.py index 6009bfa2..0700d18e 100644 --- a/tests/unit/test_redis_cluster.py +++ b/tests/unit/test_redis_cluster.py @@ -139,3 +139,17 @@ def test_connection_by_key(self): con = cluster_env.getConnectionByKey(key, "set") assert(con.set(key, "1")) cluster_env.stopEnv() + + def test_add_shard_to_cluster(self): + shardsCount = 3 + default_args = Defaults().getKwargs() + default_args['dbDirPath'] = self.test_dir + cluster_env = ClusterEnv(shardsCount=shardsCount, redisBinaryPath=REDIS_BINARY, outputFilesFormat='%s-test', + randomizePorts=Defaults.randomize_ports, **default_args) + cluster_env.startEnv() + cluster_env.addShardToCluster(REDIS_BINARY, '%s-test', **default_args) + assert cluster_env.shardsCount == shardsCount+1 + new_shard_conn = cluster_env.getConnection(shardId=4) + assert new_shard_conn.ping() + assert new_shard_conn.cluster('info')['cluster_state'] == 'ok' + cluster_env.stopEnv() diff --git a/tests/unit/test_redis_enterprise_cluster.py b/tests/unit/test_redis_enterprise_cluster.py deleted file mode 100644 index 73848199..00000000 --- a/tests/unit/test_redis_enterprise_cluster.py +++ /dev/null @@ -1,42 +0,0 @@ -import shutil -import tempfile -from unittest import TestCase - - -class TestEnterpriseRedisClusterEnv(TestCase): - - def setUp(self): - # Create a temporary directory - self.test_dir = tempfile.mkdtemp() - - def tearDown(self): - pass - # Remove the directory after the test - shutil.rmtree(self.test_dir) - - def test_wait_cluster(self): - pass - - def test_dump_and_reload(self): - pass - - def test_get_cluster_connection(self): - pass - - def test_get_connection(self): - pass - - def test_get_master_nodes_list(self): - pass - - def test_get_ossmaster_nodes_connection_list(self): - pass - - def test_is_up(self): - pass - - def test_is_unix_socket(self): - pass - - def test_is_tcp(self): - pass diff --git a/tests/unit/test_redis_std.py b/tests/unit/test_redis_std.py index 55d53dd3..8a24e8ff 100644 --- a/tests/unit/test_redis_std.py +++ b/tests/unit/test_redis_std.py @@ -89,6 +89,14 @@ def test_create_cmd_args_default(self): cmd_args = std_env.createCmdArgs(role) assert [REDIS_BINARY, '--port', '6379', '--logfile', std_env._getFileName(role, '.log'), '--dbfilename', std_env._getFileName(role, '.rdb')] == cmd_args + + def test_create_cmd_args_config_file(self): + std_env = StandardEnv(redisBinaryPath=REDIS_BINARY, outputFilesFormat='%s-test', + redisConfigFile='redis.conf') + role = 'master' + cmd_args = std_env.createCmdArgs(role) + assert [REDIS_BINARY, 'redis.conf','--port', '6379', '--logfile', std_env._getFileName(role, '.log'), '--dbfilename', + std_env._getFileName(role, '.rdb')] == cmd_args def test_create_cmd_args_tls(self): port = 8000 @@ -102,7 +110,7 @@ def test_create_cmd_args_tls(self): tls_std_env._getFileName(role, '.log'), '--dbfilename', tls_std_env._getFileName(role, '.rdb'), '--tls-cert-file', os.path.join(self.test_dir, tlsCertFile), '--tls-key-file', os.path.join(self.test_dir, tlsKeyFile), '--tls-ca-cert-file', - os.path.join(self.test_dir, tlsCaCertFile)] == cmd_args + os.path.join(self.test_dir, tlsCaCertFile), '--tls-replication', 'yes'] == cmd_args def test_create_cmd_args_modules_default_behaviour(self): port = 8000 @@ -393,4 +401,4 @@ def test_cluster_node_timeout(self): std_env.startEnv() con = std_env.getConnection() assert(con.execute_command("CONFIG", "GET", "cluster-node-timeout"), "60000") - std_env.stopEnv() \ No newline at end of file + std_env.stopEnv() diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 00000000..af01ad06 --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,110 @@ +from unittest import TestCase + +from RLTest.utils import fix_modulesArgs + + +class TestFixModulesArgs(TestCase): + + # 1. Single key-value pair string, no defaults - kept as single string + def test_single_key_value_pair(self): + result = fix_modulesArgs(['/mod.so'], 'WORKERS 4') + self.assertEqual(result, [['WORKERS 4']]) + + # 2. Multiple key-value pairs without semicolons, no defaults - kept as single string + def test_multiple_kv_pairs_no_semicolons_no_defaults(self): + result = fix_modulesArgs(['/mod.so'], '_FREE_RESOURCE_ON_THREAD FALSE TIMEOUT 80 WORKERS 4') + self.assertEqual(result, [['_FREE_RESOURCE_ON_THREAD FALSE TIMEOUT 80 WORKERS 4']]) + + # 3. Semicolon-separated args (existing behavior) + def test_semicolon_separated_args(self): + result = fix_modulesArgs(['/mod.so'], 'KEY1 V1; KEY2 V2') + self.assertEqual(result, [['KEY1 V1', 'KEY2 V2']]) + + # 4. Odd number of words without semicolons, no defaults - kept as single string, no error + def test_odd_words_no_semicolons_no_error(self): + result = fix_modulesArgs(['/mod.so'], 'FLAG TIMEOUT 80 ') + self.assertEqual(result, [['FLAG TIMEOUT 80']]) + + # 4b. Odd number of words with semicolons - valid, semicolons split first + def test_odd_words_with_semicolons_valid(self): + result = fix_modulesArgs(['/mod.so'], 'FLAG; TIMEOUT 80') + self.assertEqual(result, [['FLAG', 'TIMEOUT 80']]) + + # 5a. Plain string with defaults - word-based merge, missing defaults appended + def test_plain_string_overrides_defaults(self): + defaults = [['WORKERS 8', 'TIMEOUT 60', 'EXTRA 1']] + result = fix_modulesArgs(['/mod.so'], 'WORKERS 4 TIMEOUT 80', defaults) + # Result is a single merged string + self.assertEqual(result, [['WORKERS 4 TIMEOUT 80 EXTRA 1']]) + + # 5b. Semicolon-separated string overrides matching defaults (dict-based merge) + def test_semicolon_separated_overrides_defaults(self): + defaults = [['WORKERS 8', 'TIMEOUT 60', 'EXTRA 1']] + result = fix_modulesArgs(['/mod.so'], 'WORKERS 4; TIMEOUT 80', defaults) + result_dict = {arg.split(' ')[0]: arg for arg in result[0]} + self.assertEqual(result_dict['WORKERS'], 'WORKERS 4') + self.assertEqual(result_dict['TIMEOUT'], 'TIMEOUT 80') + self.assertEqual(result_dict['EXTRA'], 'EXTRA 1') + + # 5c. Plain string partial override - missing defaults appended + def test_plain_string_partial_override_with_defaults(self): + defaults = [['_FREE_RESOURCE_ON_THREAD TRUE', 'TIMEOUT 100', 'WORKERS 8']] + result = fix_modulesArgs(['/mod.so'], 'WORKERS 4 TIMEOUT 80', defaults) + self.assertEqual(result, [['WORKERS 4 TIMEOUT 80 _FREE_RESOURCE_ON_THREAD TRUE']]) + + # 6. None input with defaults - deep copy of defaults + def test_none_uses_defaults(self): + defaults = [['WORKERS 8', 'TIMEOUT 60']] + result = fix_modulesArgs(['/mod.so'], None, defaults) + self.assertEqual(result, defaults) + # Verify it's a deep copy + result[0][0] = 'MODIFIED' + self.assertEqual(defaults[0][0], 'WORKERS 8') + + # 7. List of strings with defaults - dict-based merge + def test_list_of_strings_with_defaults(self): + defaults = [['K1 default1', 'K2 default2', 'K4 default4']] + result = fix_modulesArgs(['/mod.so'], ['K1 override1', 'K2 override2', 'K3 new3'], defaults) + result_dict = {arg.split(' ')[0]: arg for arg in result[0]} + self.assertEqual(result_dict['K1'], 'K1 override1') + self.assertEqual(result_dict['K2'], 'K2 override2') + self.assertEqual(result_dict['K3'], 'K3 new3') + self.assertEqual(result_dict['K4'], 'K4 default4') + + # 8. List of lists (multi-module) with defaults - dict-based merge + def test_multi_module_with_defaults(self): + modules = ['/mod1.so', '/mod2.so'] + explicit = [['K1 v1', 'K2 v2'], ['K3 v3']] + defaults = [['K1 d1', 'K5 d5'], ['K3 d3', 'K4 d4']] + result = fix_modulesArgs(modules, explicit, defaults) + dict1 = {arg.split(' ')[0]: arg for arg in result[0]} + self.assertEqual(dict1['K1'], 'K1 v1') + self.assertEqual(dict1['K2'], 'K2 v2') + self.assertEqual(dict1['K5'], 'K5 d5') + dict2 = {arg.split(' ')[0]: arg for arg in result[1]} + self.assertEqual(dict2['K3'], 'K3 v3') + self.assertEqual(dict2['K4'], 'K4 d4') + + # 9. Odd words with defaults - word-based merge, flags and multi-value args handled + def test_odd_words_with_defaults(self): + defaults = [['FORK_GC_CLEAN_NUMERIC_EMPTY_NODES', 'TIMEOUT 90']] + result = fix_modulesArgs(['/mod.so'], 'workers 0 nogc FORK_GC_CLEAN_NUMERIC_EMPTY_NODES timeout 90', defaults) + self.assertEqual(result, [['workers 0 nogc FORK_GC_CLEAN_NUMERIC_EMPTY_NODES timeout 90']]) + + # 10. Plain string with defaults - unknown keys not in defaults stay, missing defaults appended + def test_plain_string_new_keys_with_defaults(self): + defaults = [['TIMEOUT 60']] + result = fix_modulesArgs(['/mod.so'], 'WORKERS 4 TIMEOUT 80', defaults) + self.assertEqual(result, [['WORKERS 4 TIMEOUT 80']]) + + # 11. Case-insensitive word matching for plain string merge + def test_case_insensitive_word_merge(self): + defaults = [['workers 8', 'TIMEOUT 60', 'EXTRA 1']] + result = fix_modulesArgs(['/mod.so'], 'WORKERS 4 timeout 80', defaults) + self.assertEqual(result, [['WORKERS 4 timeout 80 EXTRA 1']]) + + # 12. Substring key should not falsely match (GC should not match nogc) + def test_no_substring_match(self): + defaults = [['GC enabled']] + result = fix_modulesArgs(['/mod.so'], 'nogc TIMEOUT 80', defaults) + self.assertEqual(result, [['nogc TIMEOUT 80 GC enabled']])