diff --git a/CHANGE.txt b/CHANGE.txt index dc786b0..cd95a98 100644 --- a/CHANGE.txt +++ b/CHANGE.txt @@ -2,6 +2,16 @@ LabKey Python Client API News +++++++++++ +What's New in the LabKey 1.4.1 package +============================== + +*Release date: 09/08/2020* + +- Add integration tests +- NOTE: The next planned release will be 2.0.0 and is expected to drop support for Python 2.x, we plan to introduce +code that will only be compatible with Python 3.6 and beyond. Python 2.x is no longer supported by the PSF as of +January 1st, 2020. + What's New in the LabKey 1.4.0 package ============================== diff --git a/labkey/__init__.py b/labkey/__init__.py index 57f3235..5984df4 100644 --- a/labkey/__init__.py +++ b/labkey/__init__.py @@ -16,6 +16,6 @@ from labkey import domain, query, experiment, security, utils __title__ = 'labkey' -__version__ = '1.4.0' +__version__ = '1.4.1' __author__ = 'LabKey' __license__ = 'Apache License 2.0' diff --git a/test/__init__.py b/test/integration/__init__.py similarity index 100% rename from test/__init__.py rename to test/integration/__init__.py diff --git a/test/integration/conftest.py b/test/integration/conftest.py new file mode 100644 index 0000000..b88e50f --- /dev/null +++ b/test/integration/conftest.py @@ -0,0 +1,70 @@ +import os +from configparser import ConfigParser + +import pytest + +from labkey.utils import create_server_context +from labkey import container +from labkey.exceptions import QueryNotFoundError + +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = '8080' +DEFAULT_CONTEXT_PATH = 'labkey' +PROJECT_NAME = 'PythonIntegrationTests' + + +@pytest.fixture(scope='session') +def server_context_vars(): + properties_file_path = os.getenv('TEAMCITY_BUILD_PROPERTIES_FILE') + host = DEFAULT_HOST + port = DEFAULT_PORT + context_path = DEFAULT_CONTEXT_PATH + + if properties_file_path is not None: + with open(properties_file_path) as f: + contents = f.read() + # .properties files are ini files without any sections, so we need to inject one + contents = '[config]\n' + contents + parser = ConfigParser() + parser.read_string(contents) + parsed_config = parser['config'] + host = parsed_config.get('labkey.server', DEFAULT_HOST) + port = parsed_config.get('tomcat.port', DEFAULT_PORT) + context_path = parsed_config.get('labkey.contextpath', DEFAULT_CONTEXT_PATH) + + if host.startswith('http://'): + host = host.replace('http://', '') + + if context_path.startswith('/'): + context_path = context_path[1:] + + return f'{host}:{port}', context_path + + +@pytest.fixture(scope="session") +def server_context(server_context_vars): + """ + Use this fixture by adding an argument called "server_context" to your test function. It assumes you have a server + running at localhost:8080, a project name "PythonIntegrationTest", and a context path of "labkey". You will need + a netrc file configured with a valid username and password in order for API requests to work. + + :return: ServerContext + """ + server, context_path = server_context_vars + return create_server_context(server, PROJECT_NAME, context_path, use_ssl=False) + + +@pytest.fixture(autouse=True, scope="session") +def project(server_context_vars): + server, context_path = server_context_vars + context = create_server_context(server, '', context_path, use_ssl=False) + + try: + container.delete(context, PROJECT_NAME) + except QueryNotFoundError: + # The project may not exist, and that is ok. + pass + + project_ = container.create(context, PROJECT_NAME, folderType='study') + yield project_ + container.delete(context, PROJECT_NAME) diff --git a/test/integration/test_domain.py b/test/integration/test_domain.py new file mode 100644 index 0000000..5d936f7 --- /dev/null +++ b/test/integration/test_domain.py @@ -0,0 +1,238 @@ +import pytest + +from labkey.query import QueryFilter + +from labkey.domain import conditional_format, create, drop, get, save + +pytestmark = pytest.mark.integration # Mark all tests in this module as integration tests +LISTS_SCHEMA = 'lists' +LIST_NAME = 'testlist' +CONDITIONAL_FORMAT = [{ + 'filter': 'format.column~gte=25', + 'textcolor': 'ff0000', + 'backgroundcolor': 'ffffff', + 'bold': True, + 'italic': False, + 'strikethrough': False +}] +SERIALIZED_QUERY_FILTER = QueryFilter('formatted', 35, QueryFilter.Types.LESS_THAN) +SERIALIZED_CONDITIONAL_FORMAT = conditional_format( + query_filter=SERIALIZED_QUERY_FILTER, + bold=False, text_color="ffff00" +).to_json() +LIST_DEFINITION = { + 'kind': 'IntList', + 'domainDesign': { + 'name': LIST_NAME, + 'fields': [ + { + 'name': 'rowId', + 'rangeURI': 'int' + }, + { + 'name': 'formatted', + 'rangeURI': 'int', + 'conditionalFormats': CONDITIONAL_FORMAT + } + ] + }, + 'options': { + 'keyName': 'rowId', + 'keyType': 'AutoIncrementInteger' + } +} + + +@pytest.fixture(scope="function") +def list_fixture(server_context): + create(server_context, LIST_DEFINITION) + created_list = get(server_context, LISTS_SCHEMA, LIST_NAME) + yield created_list + # clean up + drop(server_context, LISTS_SCHEMA, LIST_NAME) + + +def test_add_conditional_format(server_context, list_fixture): + new_conditional_format = conditional_format( + query_filter='format.column~lte=7', + text_color='ff0055', + background_color='ffffff', + bold=True, + italic=False, + strike_through=False + ) + + for field in list_fixture.fields: + if field.name == 'formatted': + field.conditional_formats.append(new_conditional_format) + + save(server_context, LISTS_SCHEMA, LIST_NAME, list_fixture) + saved_domain = get(server_context, LISTS_SCHEMA, LIST_NAME) + + for field in saved_domain.fields: + if field.name == "formatted": + assert len(field.conditional_formats) == 2 + + +def test_add_conditional_format_with_multiple_filters(server_context, list_fixture): + new_conditional_formats = [ + conditional_format( + query_filter=[ + QueryFilter(column='column', value=10, filter_type=QueryFilter.Types.LESS_THAN), + QueryFilter(column='column', value=100, filter_type=QueryFilter.Types.GREATER_THAN) + ], + text_color='ff0055', + background_color='ffffff', + bold=True, + italic=False, + strike_through=False + ) + ] + + for field in list_fixture.fields: + if field.name == 'formatted': + field.conditional_formats = [] + field.conditional_formats = new_conditional_formats + + save(server_context, LISTS_SCHEMA, LIST_NAME, list_fixture) + saved_domain = get(server_context, LISTS_SCHEMA, LIST_NAME) + + for field in saved_domain.fields: + if field.name == "formatted": + assert field.conditional_formats.__len__() == 1 + assert field.conditional_formats[0].filter == 'format.column~lt=10&format.column~gt=100' + + +@pytest.mark.xfail # this reproduces https://www.labkey.org/home/Developer/issues/issues-details.view?issueId=41318 +def test_add_malformed_query_filter(server_context, list_fixture): + new_conditional_format = conditional_format( + query_filter='this-is-a-badly-formed-filter', + text_color='ff0055', + background_color='ffffff', + bold=True, + italic=False, + strike_through=False + ) + + for field in list_fixture.fields: + if field.name == 'formatted': + field.conditional_formats = [] + field.conditional_formats = [new_conditional_format] + + save(server_context, LISTS_SCHEMA, LIST_NAME, list_fixture) + saved_domain = get(server_context, LISTS_SCHEMA, LIST_NAME) + + for field in saved_domain.fields: + if field.name == "formatted": + assert field.conditional_formats[0].filter != 'this-is-a-badly-formed-filter', \ + "api should discard meaningless filters" + + +def test_add_conditional_format_with_missing_filter(server_context, list_fixture): + missing_filter_type_filter = QueryFilter('formatted', 13) + new_conditional_format = conditional_format( + query_filter=missing_filter_type_filter, + text_color='ff0055', + background_color='ffffff', + bold=True, + italic=False, + strike_through=False + ) + + for field in list_fixture.fields: + if field.name == 'formatted': + field.conditional_formats = [] + field.conditional_formats = [new_conditional_format] + + save(server_context, LISTS_SCHEMA, LIST_NAME, list_fixture) + saved_domain = get(server_context, LISTS_SCHEMA, LIST_NAME) + + for field in saved_domain.fields: + if field.name == "formatted": + assert field.conditional_formats[0].filter == 'format.column~eq=13' + + +def test_remove_conditional_format(server_context, list_fixture): + for field in list_fixture.fields: + if field.name == 'formatted': + field.conditional_formats = [] + + save(server_context, LISTS_SCHEMA, LIST_NAME, list_fixture) + saved_domain = get(server_context, LISTS_SCHEMA, LIST_NAME) + + for field in saved_domain.fields: + if field.name == "formatted": + assert len(field.conditional_formats) == 0 + + +def test_update_conditional_format_serialize_filter(server_context, list_fixture): + from labkey.query import QueryFilter + new_filter = QueryFilter('formatted', 15, QueryFilter.Types.GREATER_THAN_OR_EQUAL) + cf = conditional_format(new_filter, text_color="ff00ff") + + for field in list_fixture.fields: + if field.name == 'formatted': + field.conditional_formats[0] = cf + + save(server_context, LISTS_SCHEMA, LIST_NAME, list_fixture) + saved_domain = get(server_context, LISTS_SCHEMA, LIST_NAME) + + for field in saved_domain.fields: + if field.name == 'formatted': + assert field.conditional_formats[0].filter == 'format.column~gte=15' + + +def test_update_conditional_format_plain_text(server_context, list_fixture): + new_filter = "formatted~gte=15" + + for field in list_fixture.fields: + if field.name == 'formatted': + field.conditional_formats[0].filter = new_filter + + save(server_context, LISTS_SCHEMA, LIST_NAME, list_fixture) + saved_domain = get(server_context, LISTS_SCHEMA, LIST_NAME) + + for field in saved_domain.fields: + if field.name == 'formatted': + assert field.conditional_formats[0].filter == new_filter + + +def test_create_list_with_conditional_formatted_field(server_context): + composed_list_definition = { + 'kind': 'IntList', + 'domainDesign': { + 'name': 'composed_list_name', + 'fields': [ + { + 'name': 'rowId', + 'rangeURI': 'int' + }, + { + 'name': 'formatted', + 'rangeURI': 'int', + 'conditionalFormats': [ + SERIALIZED_CONDITIONAL_FORMAT, + { + 'filter': 'format.column~gte=25', + 'textcolor': 'ff0000', + 'backgroundcolor': 'ffffff', + 'bold': True, + 'italic': False, + 'strikethrough': False + } + ] + } + ] + }, + 'options': { + 'keyName': 'rowId', + 'keyType': 'AutoIncrementInteger' + } + } + create(server_context, composed_list_definition) + created_list = get(server_context, LISTS_SCHEMA, 'composed_list_name') + for field in created_list.fields: + if field.name == 'formatted': + assert len(field.conditional_formats) == 2 + + drop(server_context, LISTS_SCHEMA, 'composed_list_name') diff --git a/test/test_integration.py b/test/integration/test_query.py similarity index 70% rename from test/test_integration.py rename to test/integration/test_query.py index b816d8a..5c867f4 100644 --- a/test/test_integration.py +++ b/test/integration/test_query.py @@ -1,18 +1,11 @@ -import os -from configparser import ConfigParser - import pytest from labkey.exceptions import ServerContextError -from labkey.utils import create_server_context from labkey.query import delete_rows, insert_rows, select_rows, update_rows -from labkey import domain, container +from labkey import domain + pytestmark = pytest.mark.integration # Mark all tests in this module as integration tests -DEFAULT_HOST = 'localhost' -DEFAULT_PORT = '8080' -DEFAULT_CONTEXT_PATH = 'labkey' -PROJECT_NAME = 'PythonIntegrationTests' STUDY_NAME = 'TestStudy' SCHEMA_NAME = 'study' QUERY_NAME = 'KrankenLevel' @@ -32,56 +25,6 @@ ] -@pytest.fixture(scope='session') -def server_context_vars(): - properties_file_path = os.getenv('TEAMCITY_BUILD_PROPERTIES_FILE') - host = DEFAULT_HOST - port = DEFAULT_PORT - context_path = DEFAULT_CONTEXT_PATH - - if properties_file_path is not None: - with open(properties_file_path) as f: - contents = f.read() - # .properties files are ini files without any sections, so we need to inject one - contents = '[config]\n' + contents - parser = ConfigParser() - parser.read_string(contents) - parsed_config = parser['config'] - host = parsed_config.get('labkey.server', DEFAULT_HOST) - port = parsed_config.get('tomcat.port', DEFAULT_PORT) - context_path = parsed_config.get('labkey.contextpath', DEFAULT_CONTEXT_PATH) - - if host.startswith('http://'): - host = host.replace('http://', '') - - if context_path.startswith('/'): - context_path = context_path[1:] - - return f'{host}:{port}', context_path - - -@pytest.fixture(autouse=True, scope="session") -def project(server_context_vars): - server, context_path = server_context_vars - context = create_server_context(server, '', context_path, use_ssl=False) - project_ = container.create(context, PROJECT_NAME, folderType='study') - yield project_ - container.delete(context, PROJECT_NAME) - - -@pytest.fixture(scope="session") -def server_context(server_context_vars): - """ - Use this fixture by adding an argument called "server_context" to your test function. It assumes you have a server - running at localhost:8080, a project name "PythonIntegrationTest", and a context path of "labkey". You will need - a netrc file configured with a valid username and password in order for API requests to work. - - :return: ServerContext - """ - server, context_path = server_context_vars - return create_server_context(server, PROJECT_NAME, context_path, use_ssl=False) - - @pytest.fixture(scope="session") def study(server_context): url = server_context.build_url('study', 'createStudy.view') @@ -142,7 +85,6 @@ def test_create_dataset(dataset): def test_create_duplicate_dataset(server_context, dataset): # Dataset fixture is not used directly here, but it is an argument so it gets created and cleaned up when this test # runs - with pytest.raises(ServerContextError) as e: domain.create(server_context, DATASET_DOMAIN) @@ -169,7 +111,7 @@ def test_insert_duplicate_labeled_qc_state_produces_error(server_context, qc_sta with pytest.raises(ServerContextError) as e: dupe_qc_state = [{'label': 'needs verification', 'publicData': 'false'}] insert_rows(server_context, 'core', 'qcstate', dupe_qc_state) - + assert "500: ERROR: duplicate key value violates unique constraint" in e.value.message diff --git a/test/conftest.py b/test/unit/__init__.py similarity index 100% rename from test/conftest.py rename to test/unit/__init__.py diff --git a/test/test_domain.py b/test/unit/test_domain.py similarity index 100% rename from test/test_domain.py rename to test/unit/test_domain.py diff --git a/test/test_experiment_api.py b/test/unit/test_experiment_api.py similarity index 100% rename from test/test_experiment_api.py rename to test/unit/test_experiment_api.py diff --git a/test/test_query_api.py b/test/unit/test_query_api.py similarity index 100% rename from test/test_query_api.py rename to test/unit/test_query_api.py diff --git a/test/test_security.py b/test/unit/test_security.py similarity index 100% rename from test/test_security.py rename to test/unit/test_security.py diff --git a/test/test_unsupported.py b/test/unit/test_unsupported.py similarity index 100% rename from test/test_unsupported.py rename to test/unit/test_unsupported.py diff --git a/test/test_utils.py b/test/unit/test_utils.py similarity index 100% rename from test/test_utils.py rename to test/unit/test_utils.py diff --git a/test/utilities.py b/test/unit/utilities.py similarity index 100% rename from test/utilities.py rename to test/unit/utilities.py