IGNITE-14240 Re-factor tests
Handle authentication error.
Fix infinite recursion on failed connection on handshake.
Skip affinity test if server doesn't support protocol.
Remove travis.
This closes #19
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 3095941..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,48 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one or more
-# contributor license agreements. See the NOTICE file distributed with
-# this work for additional information regarding copyright ownership.
-# The ASF licenses this file to You under the Apache License, Version 2.0
-# (the "License"); you may not use this file except in compliance with
-# the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-language: python
-sudo: required
-
-addons:
- apt:
- packages:
- - openjdk-8-jdk
-
-env:
- global:
- - IGNITE_VERSION=2.9.1
- - IGNITE_HOME=/opt/ignite
-
-before_install:
- - curl -L https://apache-mirror.rbc.ru/pub/apache/ignite/${IGNITE_VERSION}/apache-ignite-slim-${IGNITE_VERSION}-bin.zip > ignite.zip
- - unzip ignite.zip -d /opt
- - mv /opt/apache-ignite-slim-${IGNITE_VERSION}-bin /opt/ignite
- - mv /opt/ignite/libs/optional/ignite-log4j2 /opt/ignite/libs/
-
-jobs:
- include:
- - python: '3.6'
- arch: amd64
- env: TOXENV=py36-no-ssl,py36-ssl,py36-ssl-password
- - python: '3.7'
- arch: amd64
- env: TOXENV=py37-no-ssl,py37-ssl,py37-ssl-password
- - python: '3.8'
- arch: amd64
- env: TOXENV=py38-no-ssl,py38-ssl,py38-ssl-password
-
-install: pip install tox
-script: tox
\ No newline at end of file
diff --git a/pyignite/connection/connection.py b/pyignite/connection/connection.py
index 6ab6c6a..8db304e 100644
--- a/pyignite/connection/connection.py
+++ b/pyignite/connection/connection.py
@@ -34,7 +34,7 @@
from pyignite.constants import *
from pyignite.exceptions import (
- HandshakeError, ParameterError, SocketError, connection_errors,
+ HandshakeError, ParameterError, SocketError, connection_errors, AuthenticationError,
)
from pyignite.datatypes import Byte, Int, Short, String, UUIDObject
from pyignite.datatypes.internal import Struct
@@ -43,6 +43,8 @@
from .ssl import wrap
from ..stream import BinaryStream, READ_BACKWARD
+CLIENT_STATUS_AUTH_FAILURE = 2000
+
class Connection:
"""
@@ -180,7 +182,7 @@
('length', Int),
('op_code', Byte),
])
- with BinaryStream(self, self.recv()) as stream:
+ with BinaryStream(self, self.recv(reconnect=False)) as stream:
start_class = response_start.parse(stream)
start = stream.read_ctype(start_class, direction=READ_BACKWARD)
data = response_start.to_python(start)
@@ -191,6 +193,7 @@
('version_minor', Short),
('version_patch', Short),
('message', String),
+ ('client_status', Int)
])
elif self.get_protocol_version() >= (1, 4, 0):
response_end = Struct([
@@ -267,7 +270,7 @@
with BinaryStream(self) as stream:
hs_request.from_python(stream)
- self.send(stream.getbuffer())
+ self.send(stream.getbuffer(), reconnect=False)
hs_response = self.read_response()
if hs_response['op_code'] == 0:
@@ -291,6 +294,8 @@
client_patch=protocol_version[2],
**hs_response
)
+ elif hs_response['client_status'] == CLIENT_STATUS_AUTH_FAILURE:
+ raise AuthenticationError(error_text)
raise HandshakeError((
hs_response['version_major'],
hs_response['version_minor'],
@@ -313,12 +318,13 @@
except connection_errors:
pass
- def send(self, data: Union[bytes, bytearray, memoryview], flags=None):
+ def send(self, data: Union[bytes, bytearray, memoryview], flags=None, reconnect=True):
"""
Send data down the socket.
:param data: bytes to send,
:param flags: (optional) OS-specific flags.
+ :param reconnect: (optional) reconnect on failure, default True.
"""
if self.closed:
raise SocketError('Attempt to use closed connection.')
@@ -334,7 +340,13 @@
self.reconnect()
raise
- def recv(self, flags=None) -> bytearray:
+ def recv(self, flags=None, reconnect=True) -> bytearray:
+ """
+ Receive data from the socket.
+
+ :param flags: (optional) OS-specific flags.
+ :param reconnect: (optional) reconnect on failure, default True.
+ """
def _recv(buffer, num_bytes):
bytes_to_receive = num_bytes
while bytes_to_receive > 0:
@@ -344,7 +356,8 @@
raise SocketError('Connection broken.')
except connection_errors:
self.failed = True
- self.reconnect()
+ if reconnect:
+ self.reconnect()
raise
buffer = buffer[bytes_rcvd:]
diff --git a/pyignite/constants.py b/pyignite/constants.py
index fc840d6..02f7124 100644
--- a/pyignite/constants.py
+++ b/pyignite/constants.py
@@ -49,7 +49,7 @@
PROTOCOL_STRING_ENCODING = 'utf-8'
PROTOCOL_CHAR_ENCODING = 'utf-16le'
-SSL_DEFAULT_VERSION = ssl.PROTOCOL_TLSv1_1
+SSL_DEFAULT_VERSION = ssl.PROTOCOL_TLSv1_2
SSL_DEFAULT_CIPHERS = ssl._DEFAULT_CIPHERS
FNV1_OFFSET_BASIS = 0x811c9dc5
diff --git a/pyignite/exceptions.py b/pyignite/exceptions.py
index 1b41d32..5933228 100644
--- a/pyignite/exceptions.py
+++ b/pyignite/exceptions.py
@@ -25,6 +25,15 @@
pass
+class AuthenticationError(Exception):
+ """
+ This exception is raised on authentication failure.
+ """
+
+ def __init__(self, message: str):
+ self.message = message
+
+
class HandshakeError(SocketError):
"""
This exception is raised on Ignite binary protocol handshake failure,
diff --git a/requirements/install.txt b/requirements/install.txt
index cecea8f..1ee12a9 100644
--- a/requirements/install.txt
+++ b/requirements/install.txt
@@ -1,3 +1,3 @@
# these pip packages are necessary for the pyignite to run
-attrs==18.1.0
+attrs==20.3.0
diff --git a/requirements/setup.txt b/requirements/setup.txt
index 7c55f83..d202467 100644
--- a/requirements/setup.txt
+++ b/requirements/setup.txt
@@ -1,3 +1,3 @@
# additional package for integrating pytest in setuptools
-pytest-runner==4.2
+pytest-runner==5.3.0
diff --git a/requirements/tests.txt b/requirements/tests.txt
index 893928e..5d5ae84 100644
--- a/requirements/tests.txt
+++ b/requirements/tests.txt
@@ -1,7 +1,7 @@
# these packages are used for testing
-pytest==3.6.1
-pytest-cov==2.5.1
-teamcity-messages==1.21
-psutil==5.6.5
+pytest==6.2.2
+pytest-cov==2.11.1
+teamcity-messages==1.28
+psutil==5.8.0
jinja2==2.11.3
diff --git a/tests/affinity/conftest.py b/tests/affinity/conftest.py
new file mode 100644
index 0000000..b682d01
--- /dev/null
+++ b/tests/affinity/conftest.py
@@ -0,0 +1,72 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import pytest
+
+from pyignite import Client
+from pyignite.api import cache_create, cache_destroy
+from tests.util import start_ignite_gen
+
+
+@pytest.fixture(scope='module', autouse=True)
+def server1():
+ yield from start_ignite_gen(1)
+
+
+@pytest.fixture(scope='module', autouse=True)
+def server2():
+ yield from start_ignite_gen(2)
+
+
+@pytest.fixture(scope='module', autouse=True)
+def server3():
+ yield from start_ignite_gen(3)
+
+
+@pytest.fixture
+def client():
+ client = Client(partition_aware=True)
+
+ client.connect([('127.0.0.1', 10800 + i) for i in range(1, 4)])
+
+ yield client
+
+ client.close()
+
+
+@pytest.fixture
+def client_not_connected():
+ client = Client(partition_aware=True)
+ yield client
+ client.close()
+
+
+@pytest.fixture
+def cache(connected_client):
+ cache_name = 'my_bucket'
+ conn = connected_client.random_node
+
+ cache_create(conn, cache_name)
+ yield cache_name
+ cache_destroy(conn, cache_name)
+
+
+@pytest.fixture(scope='module', autouse=True)
+def skip_if_no_affinity(request, server1):
+ client = Client(partition_aware=True)
+ client.connect('127.0.0.1', 10801)
+
+ if not client.partition_awareness_supported_by_protocol:
+ pytest.skip(f'skipped {request.node.name}, partition awareness is not supported.')
diff --git a/tests/test_affinity.py b/tests/affinity/test_affinity.py
similarity index 80%
rename from tests/test_affinity.py
rename to tests/affinity/test_affinity.py
index a55251b..ee8f6c0 100644
--- a/tests/test_affinity.py
+++ b/tests/affinity/test_affinity.py
@@ -27,12 +27,11 @@
from pyignite.datatypes.prop_codes import *
-def test_get_node_partitions(client_partition_aware):
+def test_get_node_partitions(client):
+ conn = client.random_node
- conn = client_partition_aware.random_node
-
- cache_1 = client_partition_aware.get_or_create_cache('test_cache_1')
- cache_2 = client_partition_aware.get_or_create_cache({
+ cache_1 = client.get_or_create_cache('test_cache_1')
+ cache_2 = client.get_or_create_cache({
PROP_NAME: 'test_cache_2',
PROP_CACHE_KEY_CONFIGURATION: [
{
@@ -41,9 +40,9 @@
}
],
})
- cache_3 = client_partition_aware.get_or_create_cache('test_cache_3')
- cache_4 = client_partition_aware.get_or_create_cache('test_cache_4')
- cache_5 = client_partition_aware.get_or_create_cache('test_cache_5')
+ client.get_or_create_cache('test_cache_3')
+ client.get_or_create_cache('test_cache_4')
+ client.get_or_create_cache('test_cache_5')
result = cache_get_node_partitions(
conn,
@@ -115,9 +114,8 @@
],
)
-def test_affinity(client_partition_aware, key, key_hint):
-
- cache_1 = client_partition_aware.get_or_create_cache({
+def test_affinity(client, key, key_hint):
+ cache_1 = client.get_or_create_cache({
PROP_NAME: 'test_cache_1',
PROP_CACHE_MODE: CacheMode.PARTITIONED,
})
@@ -126,7 +124,7 @@
best_node = cache_1.get_best_node(key, key_hint=key_hint)
- for node in filter(lambda n: n.alive, client_partition_aware._nodes):
+ for node in filter(lambda n: n.alive, client._nodes):
result = cache_local_peek(
node, cache_1.cache_id, key, key_hint=key_hint,
)
@@ -142,9 +140,8 @@
cache_1.destroy()
-def test_affinity_for_generic_object(client_partition_aware):
-
- cache_1 = client_partition_aware.get_or_create_cache({
+def test_affinity_for_generic_object(client):
+ cache_1 = client.get_or_create_cache({
PROP_NAME: 'test_cache_1',
PROP_CACHE_MODE: CacheMode.PARTITIONED,
})
@@ -166,7 +163,7 @@
best_node = cache_1.get_best_node(key, key_hint=BinaryObject)
- for node in filter(lambda n: n.alive, client_partition_aware._nodes):
+ for node in filter(lambda n: n.alive, client._nodes):
result = cache_local_peek(
node, cache_1.cache_id, key, key_hint=BinaryObject,
)
@@ -182,16 +179,8 @@
cache_1.destroy()
-def test_affinity_for_generic_object_without_type_hints(client_partition_aware):
-
- if not client_partition_aware.partition_awareness_supported_by_protocol:
- pytest.skip(
- 'Best effort affinity is not supported by the protocol {}.'.format(
- client_partition_aware.protocol_version
- )
- )
-
- cache_1 = client_partition_aware.get_or_create_cache({
+def test_affinity_for_generic_object_without_type_hints(client):
+ cache_1 = client.get_or_create_cache({
PROP_NAME: 'test_cache_1',
PROP_CACHE_MODE: CacheMode.PARTITIONED,
})
@@ -213,7 +202,7 @@
best_node = cache_1.get_best_node(key)
- for node in filter(lambda n: n.alive, client_partition_aware._nodes):
+ for node in filter(lambda n: n.alive, client._nodes):
result = cache_local_peek(
node, cache_1.cache_id, key
)
diff --git a/tests/test_affinity_bad_servers.py b/tests/affinity/test_affinity_bad_servers.py
similarity index 66%
rename from tests/test_affinity_bad_servers.py
rename to tests/affinity/test_affinity_bad_servers.py
index dce09de..8abf4a0 100644
--- a/tests/test_affinity_bad_servers.py
+++ b/tests/affinity/test_affinity_bad_servers.py
@@ -16,22 +16,20 @@
import pytest
from pyignite.exceptions import ReconnectError
-from tests.util import *
+from tests.util import start_ignite, kill_process_tree
-def test_client_with_multiple_bad_servers(start_client):
- client = start_client(partition_aware=True)
+def test_client_with_multiple_bad_servers(client_not_connected):
with pytest.raises(ReconnectError) as e_info:
- client.connect([("127.0.0.1", 10900), ("127.0.0.1", 10901)])
+ client_not_connected.connect([("127.0.0.1", 10900), ("127.0.0.1", 10901)])
assert str(e_info.value) == "Can not connect."
-def test_client_with_failed_server(request, start_ignite_server, start_client):
- srv = start_ignite_server(4)
+def test_client_with_failed_server(request, client_not_connected):
+ srv = start_ignite(idx=4)
try:
- client = start_client()
- client.connect([("127.0.0.1", 10804)])
- cache = client.get_or_create_cache(request.node.name)
+ client_not_connected.connect([("127.0.0.1", 10804)])
+ cache = client_not_connected.get_or_create_cache(request.node.name)
cache.put(1, 1)
kill_process_tree(srv.pid)
with pytest.raises(ConnectionResetError):
@@ -40,17 +38,16 @@
kill_process_tree(srv.pid)
-def test_client_with_recovered_server(request, start_ignite_server, start_client):
- srv = start_ignite_server(4)
+def test_client_with_recovered_server(request, client_not_connected):
+ srv = start_ignite(idx=4)
try:
- client = start_client()
- client.connect([("127.0.0.1", 10804)])
- cache = client.get_or_create_cache(request.node.name)
+ client_not_connected.connect([("127.0.0.1", 10804)])
+ cache = client_not_connected.get_or_create_cache(request.node.name)
cache.put(1, 1)
# Kill and restart server
kill_process_tree(srv.pid)
- srv = start_ignite_server(4)
+ srv = start_ignite(idx=4)
# First request fails
with pytest.raises(Exception):
diff --git a/tests/test_affinity_request_routing.py b/tests/affinity/test_affinity_request_routing.py
similarity index 88%
rename from tests/test_affinity_request_routing.py
rename to tests/affinity/test_affinity_request_routing.py
index 3489dea..101db39 100644
--- a/tests/test_affinity_request_routing.py
+++ b/tests/affinity/test_affinity_request_routing.py
@@ -70,10 +70,8 @@
@pytest.mark.parametrize("key,grid_idx", [(1, 1), (2, 2), (3, 3), (4, 1), (5, 1), (6, 2), (11, 1), (13, 1), (19, 1)])
@pytest.mark.parametrize("backups", [0, 1, 2, 3])
-def test_cache_operation_on_primitive_key_routes_request_to_primary_node(
- request, key, grid_idx, backups, client_partition_aware):
-
- cache = client_partition_aware.get_or_create_cache({
+def test_cache_operation_on_primitive_key_routes_request_to_primary_node(request, key, grid_idx, backups, client):
+ cache = client.get_or_create_cache({
PROP_NAME: request.node.name + str(backups),
PROP_BACKUPS_NUMBER: backups,
})
@@ -132,8 +130,7 @@
@pytest.mark.parametrize("key,grid_idx", [(1, 2), (2, 1), (3, 1), (4, 2), (5, 2), (6, 3)])
@pytest.mark.skip(reason="Custom key objects are not supported yet")
-def test_cache_operation_on_custom_affinity_key_routes_request_to_primary_node(
- request, client_partition_aware, key, grid_idx):
+def test_cache_operation_on_custom_affinity_key_routes_request_to_primary_node(request, client, key, grid_idx):
class AffinityTestType1(
metaclass=GenericObjectMeta,
type_name='AffinityTestType1',
@@ -153,7 +150,7 @@
},
],
}
- cache = client_partition_aware.create_cache(cache_config)
+ cache = client.create_cache(cache_config)
# noinspection PyArgumentList
key_obj = AffinityTestType1(
@@ -167,17 +164,18 @@
assert requests.pop() == grid_idx
-def test_cache_operation_routed_to_new_cluster_node(request, start_ignite_server, start_client):
- client = start_client(partition_aware=True)
- client.connect([("127.0.0.1", 10801), ("127.0.0.1", 10802), ("127.0.0.1", 10803), ("127.0.0.1", 10804)])
- cache = client.get_or_create_cache(request.node.name)
+def test_cache_operation_routed_to_new_cluster_node(request, client_not_connected):
+ client_not_connected.connect(
+ [("127.0.0.1", 10801), ("127.0.0.1", 10802), ("127.0.0.1", 10803), ("127.0.0.1", 10804)]
+ )
+ cache = client_not_connected.get_or_create_cache(request.node.name)
key = 12
wait_for_affinity_distribution(cache, key, 3)
cache.put(key, key)
cache.put(key, key)
assert requests.pop() == 3
- srv = start_ignite_server(4)
+ srv = start_ignite(idx=4)
try:
# Wait for rebalance and partition map exchange
wait_for_affinity_distribution(cache, key, 4)
@@ -190,8 +188,8 @@
kill_process_tree(srv.pid)
-def test_replicated_cache_operation_routed_to_random_node(request, client_partition_aware):
- cache = client_partition_aware.get_or_create_cache({
+def test_replicated_cache_operation_routed_to_random_node(request, client):
+ cache = client.get_or_create_cache({
PROP_NAME: request.node.name,
PROP_CACHE_MODE: CacheMode.REPLICATED,
})
diff --git a/tests/test_affinity_single_connection.py b/tests/affinity/test_affinity_single_connection.py
similarity index 89%
rename from tests/test_affinity_single_connection.py
rename to tests/affinity/test_affinity_single_connection.py
index 1943384..0768011 100644
--- a/tests/test_affinity_single_connection.py
+++ b/tests/affinity/test_affinity_single_connection.py
@@ -13,9 +13,21 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import pytest
-def test_all_cache_operations_with_partition_aware_client_on_single_server(request, client_partition_aware_single_server):
- cache = client_partition_aware_single_server.get_or_create_cache(request.node.name)
+from pyignite import Client
+
+
+@pytest.fixture(scope='module')
+def client():
+ client = Client(partition_aware=True)
+ client.connect('127.0.0.1', 10801)
+ yield client
+ client.close()
+
+
+def test_all_cache_operations_with_partition_aware_client_on_single_server(request, client):
+ cache = client.get_or_create_cache(request.node.name)
key = 1
key2 = 2
diff --git a/tests/common/conftest.py b/tests/common/conftest.py
new file mode 100644
index 0000000..402aede
--- /dev/null
+++ b/tests/common/conftest.py
@@ -0,0 +1,56 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import pytest
+
+from pyignite import Client
+from pyignite.api import cache_create, cache_destroy
+from tests.util import start_ignite_gen
+
+
+@pytest.fixture(scope='module', autouse=True)
+def server1():
+ yield from start_ignite_gen(1)
+
+
+@pytest.fixture(scope='module', autouse=True)
+def server2():
+ yield from start_ignite_gen(2)
+
+
+@pytest.fixture(scope='module', autouse=True)
+def server3():
+ yield from start_ignite_gen(3)
+
+
+@pytest.fixture(scope='module')
+def client():
+ client = Client()
+
+ client.connect('127.0.0.1', 10801)
+
+ yield client
+
+ client.close()
+
+
+@pytest.fixture
+def cache(client):
+ cache_name = 'my_bucket'
+ conn = client.random_node
+
+ cache_create(conn, cache_name)
+ yield cache_name
+ cache_destroy(conn, cache_name)
diff --git a/tests/test_binary.py b/tests/common/test_binary.py
similarity index 100%
rename from tests/test_binary.py
rename to tests/common/test_binary.py
diff --git a/tests/test_cache_class.py b/tests/common/test_cache_class.py
similarity index 100%
rename from tests/test_cache_class.py
rename to tests/common/test_cache_class.py
diff --git a/tests/test_cache_class_sql.py b/tests/common/test_cache_class_sql.py
similarity index 100%
rename from tests/test_cache_class_sql.py
rename to tests/common/test_cache_class_sql.py
diff --git a/tests/test_cache_composite_key_class_sql.py b/tests/common/test_cache_composite_key_class_sql.py
similarity index 100%
rename from tests/test_cache_composite_key_class_sql.py
rename to tests/common/test_cache_composite_key_class_sql.py
diff --git a/tests/test_cache_config.py b/tests/common/test_cache_config.py
similarity index 100%
rename from tests/test_cache_config.py
rename to tests/common/test_cache_config.py
diff --git a/tests/test_datatypes.py b/tests/common/test_datatypes.py
similarity index 100%
rename from tests/test_datatypes.py
rename to tests/common/test_datatypes.py
diff --git a/tests/test_generic_object.py b/tests/common/test_generic_object.py
similarity index 100%
rename from tests/test_generic_object.py
rename to tests/common/test_generic_object.py
diff --git a/tests/test_get_names.py b/tests/common/test_get_names.py
similarity index 100%
rename from tests/test_get_names.py
rename to tests/common/test_get_names.py
diff --git a/tests/test_key_value.py b/tests/common/test_key_value.py
similarity index 100%
rename from tests/test_key_value.py
rename to tests/common/test_key_value.py
diff --git a/tests/test_scan.py b/tests/common/test_scan.py
similarity index 100%
rename from tests/test_scan.py
rename to tests/common/test_scan.py
diff --git a/tests/test_sql.py b/tests/common/test_sql.py
similarity index 98%
rename from tests/test_sql.py
rename to tests/common/test_sql.py
index f25fedd..cc68a02 100644
--- a/tests/test_sql.py
+++ b/tests/common/test_sql.py
@@ -182,7 +182,7 @@
client.sql('DROP TABLE LongMultipageQuery IF EXISTS')
client.sql("CREATE TABLE LongMultiPageQuery (%s, %s)" %
- (fields[0] + " INT(11) PRIMARY KEY", ",".join(map(lambda f: f + " INT(11)", fields[1:]))))
+ (fields[0] + " INT(11) PRIMARY KEY", ",".join(map(lambda f: f + " INT(11)", fields[1:]))))
for id in range(1, 21):
client.sql(
diff --git a/tests/config/ignite-config.xml.jinja2 b/tests/config/ignite-config.xml.jinja2
index 834b5d8..85daf0f 100644
--- a/tests/config/ignite-config.xml.jinja2
+++ b/tests/config/ignite-config.xml.jinja2
@@ -27,6 +27,20 @@
http://www.springframework.org/schema/util/spring-util.xsd">
<bean id="grid.cfg" class="org.apache.ignite.configuration.IgniteConfiguration">
+ {% if use_auth %}
+ <property name="dataStorageConfiguration">
+ <bean class="org.apache.ignite.configuration.DataStorageConfiguration">
+ <property name="defaultDataRegionConfiguration">
+ <bean class="org.apache.ignite.configuration.DataRegionConfiguration">
+ <property name="persistenceEnabled" value="true"/>
+ </bean>
+ </property>
+ </bean>
+ </property>
+
+ <property name="authenticationEnabled" value="true"/>
+ {% endif %}
+
{% if use_ssl %}
<property name="connectorConfiguration"><null/></property>
{% endif %}
diff --git a/tests/conftest.py b/tests/conftest.py
index bd86f9c..59b7d3a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -12,188 +12,14 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-
-import argparse
-from distutils.util import strtobool
-import ssl
-
import pytest
-from pyignite import Client
-from pyignite.constants import *
-from pyignite.api import cache_create, cache_destroy
-from tests.util import _start_ignite, start_ignite_gen
-
-
-class BoolParser(argparse.Action):
-
- def __call__(self, parser, namespace, values, option_string=None):
- values = True if values is None else bool(strtobool(values))
- setattr(namespace, self.dest, values)
-
-
-class CertReqsParser(argparse.Action):
- conv_map = {
- 'NONE': ssl.CERT_NONE,
- 'OPTIONAL': ssl.CERT_OPTIONAL,
- 'REQUIRED': ssl.CERT_REQUIRED,
- }
-
- def __call__(self, parser, namespace, values, option_string=None):
- value = values.upper()
- if value in self.conv_map:
- setattr(namespace, self.dest, self.conv_map[value])
- else:
- raise ValueError(
- 'Undefined argument: --ssl-cert-reqs={}'.format(value)
- )
-
-
-class SSLVersionParser(argparse.Action):
- conv_map = {
- 'TLSV1_1': ssl.PROTOCOL_TLSv1_1,
- 'TLSV1_2': ssl.PROTOCOL_TLSv1_2,
- }
-
- def __call__(self, parser, namespace, values, option_string=None):
- value = values.upper()
- if value in self.conv_map:
- setattr(namespace, self.dest, self.conv_map[value])
- else:
- raise ValueError(
- 'Undefined argument: --ssl-version={}'.format(value)
- )
-
-
-@pytest.fixture(scope='session', autouse=True)
-def server1(request):
- yield from start_ignite_server_gen(1, request)
-
-
-@pytest.fixture(scope='session', autouse=True)
-def server2(request):
- yield from start_ignite_server_gen(2, request)
-
-
-@pytest.fixture(scope='session', autouse=True)
-def server3(request):
- yield from start_ignite_server_gen(3, request)
-
-
-@pytest.fixture(scope='module')
-def start_ignite_server(use_ssl):
- def start(idx=1):
- return _start_ignite(idx, use_ssl=use_ssl)
-
- return start
-
-
-def start_ignite_server_gen(idx, request):
- use_ssl = request.config.getoption("--use-ssl")
- yield from start_ignite_gen(idx, use_ssl)
-
-
-@pytest.fixture(scope='module')
-def client(
- node, timeout, partition_aware, use_ssl, ssl_keyfile, ssl_keyfile_password,
- ssl_certfile, ssl_ca_certfile, ssl_cert_reqs, ssl_ciphers, ssl_version,
- username, password,
-):
- yield from client0(node, timeout, partition_aware, use_ssl, ssl_keyfile, ssl_keyfile_password, ssl_certfile,
- ssl_ca_certfile, ssl_cert_reqs, ssl_ciphers, ssl_version, username, password)
-
-
-@pytest.fixture(scope='module')
-def client_partition_aware(
- node, timeout, use_ssl, ssl_keyfile, ssl_keyfile_password, ssl_certfile,
- ssl_ca_certfile, ssl_cert_reqs, ssl_ciphers, ssl_version, username,
- password
-):
- yield from client0(node, timeout, True, use_ssl, ssl_keyfile, ssl_keyfile_password, ssl_certfile, ssl_ca_certfile,
- ssl_cert_reqs, ssl_ciphers, ssl_version, username, password)
-
-
-@pytest.fixture(scope='module')
-def client_partition_aware_single_server(
- node, timeout, use_ssl, ssl_keyfile, ssl_keyfile_password, ssl_certfile,
- ssl_ca_certfile, ssl_cert_reqs, ssl_ciphers, ssl_version, username,
- password
-):
- node = node[:1]
- yield from client0(node, timeout, True, use_ssl, ssl_keyfile, ssl_keyfile_password, ssl_certfile, ssl_ca_certfile,
- ssl_cert_reqs, ssl_ciphers, ssl_version, username, password)
-
-
-@pytest.fixture
-def cache(client):
- cache_name = 'my_bucket'
- conn = client.random_node
-
- cache_create(conn, cache_name)
- yield cache_name
- cache_destroy(conn, cache_name)
-
-
-@pytest.fixture(scope='module')
-def start_client(use_ssl, ssl_keyfile, ssl_keyfile_password, ssl_certfile, ssl_ca_certfile, ssl_cert_reqs, ssl_ciphers,
- ssl_version,username, password):
- def start(**kwargs):
- cli_kw = kwargs.copy()
- cli_kw.update({
- 'use_ssl': use_ssl,
- 'ssl_keyfile': ssl_keyfile,
- 'ssl_keyfile_password': ssl_keyfile_password,
- 'ssl_certfile': ssl_certfile,
- 'ssl_ca_certfile': ssl_ca_certfile,
- 'ssl_cert_reqs': ssl_cert_reqs,
- 'ssl_ciphers': ssl_ciphers,
- 'ssl_version': ssl_version,
- 'username': username,
- 'password': password
- })
- return Client(**cli_kw)
-
- return start
-
-
-def client0(
- node, timeout, partition_aware, use_ssl, ssl_keyfile, ssl_keyfile_password,
- ssl_certfile, ssl_ca_certfile, ssl_cert_reqs, ssl_ciphers, ssl_version,
- username, password,
-):
- client = Client(
- timeout=timeout,
- partition_aware=partition_aware,
- use_ssl=use_ssl,
- ssl_keyfile=ssl_keyfile,
- ssl_keyfile_password=ssl_keyfile_password,
- ssl_certfile=ssl_certfile,
- ssl_ca_certfile=ssl_ca_certfile,
- ssl_cert_reqs=ssl_cert_reqs,
- ssl_ciphers=ssl_ciphers,
- ssl_version=ssl_version,
- username=username,
- password=password,
- )
- nodes = []
- for n in node:
- host, port = n.split(':')
- port = int(port)
- nodes.append((host, port))
- client.connect(nodes)
- yield client
- client.close()
-
-
-@pytest.fixture
-def examples(request):
- return request.config.getoption("--examples")
-
@pytest.fixture(autouse=True)
-def run_examples(request, examples):
+def run_examples(request):
+ run_examples = request.config.getoption("--examples")
if request.node.get_closest_marker('examples'):
- if not examples:
+ if not run_examples:
pytest.skip('skipped examples: --examples is not passed')
@@ -214,103 +40,6 @@
def pytest_addoption(parser):
parser.addoption(
- '--node',
- action='append',
- default=None,
- help=(
- 'Ignite binary protocol test server connection string '
- '(default: "localhost:10801")'
- )
- )
- parser.addoption(
- '--timeout',
- action='store',
- type=float,
- default=2.0,
- help=(
- 'Timeout (in seconds) for each socket operation. Can accept '
- 'integer or float value. Default is None'
- )
- )
- parser.addoption(
- '--partition-aware',
- action=BoolParser,
- nargs='?',
- default=False,
- help='Turn on the best effort affinity feature'
- )
- parser.addoption(
- '--use-ssl',
- action=BoolParser,
- nargs='?',
- default=False,
- help='Use SSL encryption'
- )
- parser.addoption(
- '--ssl-keyfile',
- action='store',
- default=None,
- type=str,
- help='a path to SSL key file to identify local party'
- )
- parser.addoption(
- '--ssl-keyfile-password',
- action='store',
- default=None,
- type=str,
- help='password for SSL key file'
- )
- parser.addoption(
- '--ssl-certfile',
- action='store',
- default=None,
- type=str,
- help='a path to ssl certificate file to identify local party'
- )
- parser.addoption(
- '--ssl-ca-certfile',
- action='store',
- default=None,
- type=str,
- help='a path to a trusted certificate or a certificate chain'
- )
- parser.addoption(
- '--ssl-cert-reqs',
- action=CertReqsParser,
- default=ssl.CERT_NONE,
- help=(
- 'determines how the remote side certificate is treated: '
- 'NONE (ignore, default), '
- 'OPTIONAL (validate, if provided) or '
- 'REQUIRED (valid remote certificate is required)'
- )
- )
- parser.addoption(
- '--ssl-ciphers',
- action='store',
- default=SSL_DEFAULT_CIPHERS,
- type=str,
- help='ciphers to use'
- )
- parser.addoption(
- '--ssl-version',
- action=SSLVersionParser,
- default=SSL_DEFAULT_VERSION,
- help='SSL version: TLSV1_1 or TLSV1_2'
- )
- parser.addoption(
- '--username',
- action='store',
- type=str,
- help='user name'
- )
- parser.addoption(
- '--password',
- action='store',
- type=str,
- help='password'
- )
- parser.addoption(
'--examples',
action='store_true',
help='check if examples can be run',
@@ -322,38 +51,11 @@
)
-def pytest_generate_tests(metafunc):
- session_parameters = {
- 'node': ['{host}:{port}'.format(host='127.0.0.1', port=10801),
- '{host}:{port}'.format(host='127.0.0.1', port=10802),
- '{host}:{port}'.format(host='127.0.0.1', port=10803)],
- 'timeout': None,
- 'partition_aware': False,
- 'use_ssl': False,
- 'ssl_keyfile': None,
- 'ssl_keyfile_password': None,
- 'ssl_certfile': None,
- 'ssl_ca_certfile': None,
- 'ssl_cert_reqs': ssl.CERT_NONE,
- 'ssl_ciphers': SSL_DEFAULT_CIPHERS,
- 'ssl_version': SSL_DEFAULT_VERSION,
- 'username': None,
- 'password': None,
- }
-
- for param_name in session_parameters:
- if param_name in metafunc.fixturenames:
- param = metafunc.config.getoption(param_name)
- # TODO: This does not work for bool
- if param is None:
- param = session_parameters[param_name]
- if param_name == 'node' or type(param) is not list:
- param = [param]
- metafunc.parametrize(param_name, param, scope='session')
-
-
def pytest_configure(config):
- config.addinivalue_line(
- "markers", "examples: mark test to run only if --examples are set\n"
- "skip_if_no_cext: mark test to run only if c extension is available"
- )
+ marker_docs = [
+ "skip_if_no_cext: mark test to run only if c extension is available",
+ "examples: mark test to run only if --examples are set"
+ ]
+
+ for marker_doc in marker_docs:
+ config.addinivalue_line("markers", marker_doc)
diff --git a/tests/security/conftest.py b/tests/security/conftest.py
new file mode 100644
index 0000000..d5de5a1
--- /dev/null
+++ b/tests/security/conftest.py
@@ -0,0 +1,49 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import os
+
+import pytest
+
+from tests.util import get_test_dir
+
+
+@pytest.fixture
+def ssl_params():
+ yield __create_ssl_param(False)
+
+
+@pytest.fixture
+def ssl_params_with_password():
+ yield __create_ssl_param(True)
+
+
+def __create_ssl_param(with_password=False):
+ cert_path = os.path.join(get_test_dir(), 'config', 'ssl')
+
+ if with_password:
+ cert = os.path.join(cert_path, 'client_with_pass_full.pem')
+ return {
+ 'ssl_keyfile': cert,
+ 'ssl_keyfile_password': '654321',
+ 'ssl_certfile': cert,
+ 'ssl_ca_certfile': cert,
+ }
+ else:
+ cert = os.path.join(cert_path, 'client_full.pem')
+ return {
+ 'ssl_keyfile': cert,
+ 'ssl_certfile': cert,
+ 'ssl_ca_certfile': cert
+ }
diff --git a/tests/security/test_auth.py b/tests/security/test_auth.py
new file mode 100644
index 0000000..2dd19a0
--- /dev/null
+++ b/tests/security/test_auth.py
@@ -0,0 +1,63 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import pytest
+
+from pyignite.exceptions import AuthenticationError
+from tests.util import start_ignite_gen, clear_ignite_work_dir, get_client
+
+DEFAULT_IGNITE_USERNAME = 'ignite'
+DEFAULT_IGNITE_PASSWORD = 'ignite'
+
+
+@pytest.fixture(params=['with-ssl', 'without-ssl'])
+def with_ssl(request):
+ return request.param == 'with-ssl'
+
+
+@pytest.fixture(autouse=True)
+def server(with_ssl, cleanup):
+ yield from start_ignite_gen(use_ssl=with_ssl, use_auth=True)
+
+
+@pytest.fixture(scope='module', autouse=True)
+def cleanup():
+ clear_ignite_work_dir()
+ yield None
+ clear_ignite_work_dir()
+
+
+def test_auth_success(with_ssl, ssl_params):
+ ssl_params['use_ssl'] = with_ssl
+
+ with get_client(username=DEFAULT_IGNITE_USERNAME, password=DEFAULT_IGNITE_PASSWORD, **ssl_params) as client:
+ client.connect("127.0.0.1", 10801)
+
+ assert all(node.alive for node in client._nodes)
+
+
+@pytest.mark.parametrize(
+ 'username, password',
+ [
+ [DEFAULT_IGNITE_USERNAME, None],
+ ['invalid_user', 'invalid_password'],
+ [None, None]
+ ]
+)
+def test_auth_failed(username, password, with_ssl, ssl_params):
+ ssl_params['use_ssl'] = with_ssl
+
+ with pytest.raises(AuthenticationError):
+ with get_client(username=username, password=password, **ssl_params) as client:
+ client.connect("127.0.0.1", 10801)
diff --git a/tests/security/test_ssl.py b/tests/security/test_ssl.py
new file mode 100644
index 0000000..6463a03
--- /dev/null
+++ b/tests/security/test_ssl.py
@@ -0,0 +1,56 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import pytest
+
+from pyignite.exceptions import ReconnectError
+from tests.util import start_ignite_gen, get_client, get_or_create_cache
+
+
+@pytest.fixture(scope='module', autouse=True)
+def server():
+ yield from start_ignite_gen(use_ssl=True, use_auth=False)
+
+
+def test_connect_ssl_keystore_with_password(ssl_params_with_password):
+ __test_connect_ssl(**ssl_params_with_password)
+
+
+def test_connect_ssl(ssl_params):
+ __test_connect_ssl(**ssl_params)
+
+def __test_connect_ssl(**kwargs):
+ kwargs['use_ssl'] = True
+
+ with get_client(**kwargs) as client:
+ client.connect("127.0.0.1", 10801)
+
+ with get_or_create_cache(client, 'test-cache') as cache:
+ cache.put(1, 1)
+
+ assert cache.get(1) == 1
+
+
+@pytest.mark.parametrize(
+ 'invalid_ssl_params',
+ [
+ {'use_ssl': False},
+ {'use_ssl': True},
+ {'use_ssl': True, 'ssl_keyfile': 'invalid.pem', 'ssl_certfile': 'invalid.pem'}
+ ]
+)
+def test_connection_error_with_incorrect_config(invalid_ssl_params):
+ with pytest.raises(ReconnectError):
+ with get_client(**invalid_ssl_params) as client:
+ client.connect([("127.0.0.1", 10801)])
diff --git a/tests/test_examples.py b/tests/test_examples.py
index 046eb6d..f90ed17 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -12,40 +12,41 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-
import glob
+import os
import subprocess
import sys
import pytest
+from tests.util import get_test_dir, start_ignite_gen
SKIP_LIST = [
'failover.py', # it hangs by design
]
-def run_subprocess_34(script: str):
- return subprocess.call([
- 'python',
- '../examples/{}'.format(script),
- ])
+def examples_scripts_gen():
+ examples_dir = os.path.join(get_test_dir(), '..', 'examples')
+ for script in glob.glob1(examples_dir, '*.py'):
+ if script not in SKIP_LIST:
+ yield os.path.join(examples_dir, script)
-def run_subprocess_35(script: str):
- return subprocess.run([
- 'python',
- '../examples/{}'.format(script),
- ]).returncode
+@pytest.fixture(autouse=True)
+def server():
+ yield from start_ignite_gen(idx=0) # idx=0, because 10800 port is needed for examples.
@pytest.mark.examples
-def test_examples():
- for script in glob.glob1('../examples', '*.py'):
- if script not in SKIP_LIST:
- # `subprocess` module was refactored in Python 3.5
- if sys.version_info >= (3, 5):
- return_code = run_subprocess_35(script)
- else:
- return_code = run_subprocess_34(script)
- assert return_code == 0
+@pytest.mark.parametrize(
+ 'example_script',
+ examples_scripts_gen()
+)
+def test_examples(example_script):
+ proc = subprocess.run([
+ sys.executable,
+ example_script
+ ])
+
+ assert proc.returncode == 0
diff --git a/tests/util.py b/tests/util.py
index 90f0146..af4c324 100644
--- a/tests/util.py
+++ b/tests/util.py
@@ -12,9 +12,10 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-
+import contextlib
import glob
import os
+import shutil
import jinja2 as jinja2
import psutil
@@ -23,6 +24,26 @@
import subprocess
import time
+from pyignite import Client
+
+
+@contextlib.contextmanager
+def get_client(**kwargs):
+ client = Client(**kwargs)
+ try:
+ yield client
+ finally:
+ client.close()
+
+
+@contextlib.contextmanager
+def get_or_create_cache(client, cache_name):
+ cache = client.get_or_create_cache(cache_name)
+ try:
+ yield cache
+ finally:
+ cache.destroy()
+
def wait_for_condition(condition, interval=0.1, timeout=10, error=None):
start = time.time()
@@ -111,7 +132,7 @@
f.write(template.render(**kwargs))
-def _start_ignite(idx=1, debug=False, use_ssl=False):
+def start_ignite(idx=1, debug=False, use_ssl=False, use_auth=False):
clear_logs(idx)
runner = get_ignite_runner()
@@ -122,7 +143,8 @@
env["JVM_OPTS"] = "-Djava.net.preferIPv4Stack=true -Xdebug -Xnoagent -Djava.compiler=NONE " \
"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 "
- params = {'ignite_instance_idx': str(idx), 'ignite_client_port': 10800 + idx, 'use_ssl': use_ssl}
+ params = {'ignite_instance_idx': str(idx), 'ignite_client_port': 10800 + idx, 'use_ssl': use_ssl,
+ 'use_auth': use_auth}
create_config_file('log4j.xml.jinja2', f'log4j-{idx}.xml', **params)
create_config_file('ignite-config.xml.jinja2', f'ignite-config-{idx}.xml', **params)
@@ -140,10 +162,12 @@
raise Exception("Failed to start Ignite: timeout while trying to connect")
-def start_ignite_gen(idx=1, use_ssl=False):
- srv = _start_ignite(idx, use_ssl=use_ssl)
- yield srv
- kill_process_tree(srv.pid)
+def start_ignite_gen(idx=1, use_ssl=False, use_auth=False):
+ srv = start_ignite(idx, use_ssl=use_ssl, use_auth=use_auth)
+ try:
+ yield srv
+ finally:
+ kill_process_tree(srv.pid)
def get_log_files(idx=1):
@@ -151,6 +175,13 @@
return glob.glob(logs_pattern)
+def clear_ignite_work_dir():
+ for ignite_dir in get_ignite_dirs():
+ work_dir = os.path.join(ignite_dir, 'work')
+ if os.path.exists(work_dir):
+ shutil.rmtree(work_dir, ignore_errors=True)
+
+
def clear_logs(idx=1):
for f in get_log_files(idx):
os.remove(f)
diff --git a/tox.ini b/tox.ini
index 104a705..3ab8dea 100644
--- a/tox.ini
+++ b/tox.ini
@@ -15,7 +15,7 @@
[tox]
skipsdist = True
-envlist = py{36,37,38}-{no-ssl,ssl,ssl-password}
+envlist = py{36,37,38,39}
[testenv]
passenv = TEAMCITY_VERSION IGNITE_HOME
@@ -26,44 +26,8 @@
recreate = True
usedevelop = True
commands =
- pytest {env:PYTESTARGS:} {posargs} --force-cext
+ pytest {env:PYTESTARGS:} {posargs} --force-cext --examples
-[jenkins]
+[testenv:py{36,37,38,39}-jenkins]
setenv:
PYTESTARGS = --junitxml=junit-{envname}.xml
-
-[no-ssl]
-setenv:
- PYTEST_ADDOPTS = --examples
-
-[ssl]
-setenv:
- PYTEST_ADDOPTS = --examples --use-ssl=True --ssl-certfile={toxinidir}/tests/config/ssl/client_full.pem --ssl-version=TLSV1_2
-
-[ssl-password]
-setenv:
- PYTEST_ADDOPTS = --examples --use-ssl=True --ssl-certfile={toxinidir}/tests/config/ssl/client_with_pass_full.pem --ssl-keyfile-password=654321 --ssl-version=TLSV1_2
-
-[testenv:py{36,37,38}-no-ssl]
-setenv: {[no-ssl]setenv}
-
-[testenv:py{36,37,38}-ssl]
-setenv: {[ssl]setenv}
-
-[testenv:py{36,37,38}-ssl-password]
-setenv: {[ssl-password]setenv}
-
-[testenv:py{36,37,38}-jenkins-no-ssl]
-setenv:
- {[no-ssl]setenv}
- {[jenkins]setenv}
-
-[testenv:py{36,37,38}-jenkins-ssl]
-setenv:
- {[ssl]setenv}
- {[jenkins]setenv}
-
-[testenv:py{36,37,38}-jenkins-ssl-password]
-setenv:
- {[ssl-password]setenv}
- {[jenkins]setenv}