diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000000000000000000000000000000000000..df29a127ca3480867828e46b7d703f3ca33854ee --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +source=kvalikirstu2 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0371e0f0529016dc8053323da469406e44025070..73c1f4b5142741c9921a1305949cc2c0b4bba81a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,11 +25,11 @@ docs/_static docs/_templates .hgignore .babelrc -.coveragerc .hg/ .hgtags .idea/ -.pylintrc .tox dist/ kvalikirstu2.egg-info/ +build/* +tests/test files/* \ No newline at end of file diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000000000000000000000000000000000000..a63f80b63deb196ad4bec5f0ee89542544825b86 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,2 @@ +[FORMAT] +max-line-length=120 \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000000000000000000000000000000000000..de9ead0d6c1f9f582267d6c7ba3176c087117805 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,159 @@ +#!/usr/bin/env groovy + +node_name = 'default' +email_recipient = '' +pylint_targets = 'kvalikirstu2' + +def sendmail(build_result) { + stage('Send mail') { + mail(to: email_recipient, + subject: "Job '${env.JOB_NAME}' (${env.BUILD_NUMBER}) result is ${build_result}", + body: "See info at ${env.BUILD_URL}") + } +} + +// Discard old builds +properties([buildDiscarder(logRotator(artifactDaysToKeepStr: '', artifactNumToKeepStr: '', daysToKeepStr: '', numToKeepStr: '30'))]) + +// node reserves executor and workspace +node(node_name) { + // Prepare + // ------- + def toxEnvName = '.env-tox' + def pylintEnvName = '.env-pylint' + def sqScannerHome = tool 'SonarQube Scanner' + def pylint_report_path = 'pylint_report.txt' + def coverage_xml_path = 'coverage.xml' + def sonar_properties_path = 'sonar-project.properties' + + // prepare workspace + def myworkspace = '' + // Get current node, to force concurrent steps to be run on the same node. + def current_node = "${env.NODE_NAME}" + + // Parallelism causes stages to be run in different workspaces. + // Before submitting to SonarQube we need to make sure pylint_report + // coverage.xml and sonar-project.properties files are in-place. + // tasks shall be run in parallel + def tasks_1 = [:] + def tasks_2 = [:] + + myworkspace = "${WORKSPACE}" + echo "My workspace is ${myworkspace}" + deleteDir() + + // Get recipient from git commit author + def scmVars = checkout scm + email_recipient = sh ( + script: "git show -s --pretty=%ae", + returnStdout: true + ) + echo "Build result will be sent to ${email_recipient}" + + // Assign parallel tasks + tasks_1['Prepare Tox, Run With Coverage & Publish Report'] = { + node(current_node) { + dir(myworkspace) { + stage('Prepare Tox Venv') { + if (!fileExists(toxEnvName)) { + echo 'Build Python Virtualenv for testing...' + sh """ + python-latest -m venv ${toxEnvName} + . ./${toxEnvName}/bin/activate + pip install --upgrade pip + pip install tox + """ + } + } + stage('Run Test Suite & Gather Coverage') { + sh """ + . ./${toxEnvName}/bin/activate + tox -e jenkins-with-coverage + """ + } + stage('Publish Cobertura Report') { + cobertura(coberturaReportFile: "${coverage_xml_path}", + zoomCoverageChart: false) + } + } + } + } + tasks_1['Prepare Pylint, Run Analysis, Archive & Publish report'] = { + node(current_node) { + dir(myworkspace) { + stage('Prepare Pylint Venv') { + if (!fileExists(pylintEnvName)) { + echo 'Build Python Virtualenv for linting...' + sh """ + python-latest -m venv ${pylintEnvName} + . ./${pylintEnvName}/bin/activate + pip install --upgrade pip + pip install -r ./requirements.txt + pip install . + pip install pylint + """ + } + } + stage('Run PyLint') { + echo 'Run pylint' + sh """ + . ./${pylintEnvName}/bin/activate + pylint -f parseable ${pylint_targets} | tee ${pylint_report_path} + """ + } + stage('Archive PyLint Report') { + archiveArtifacts artifacts: pylint_report_path + } + stage('Publish PyLint Report') { + recordIssues tool: pyLint(pattern: pylint_report_path) + } + } + } + } + tasks_2['Run Tests py311'] = { + node(current_node) { + dir(myworkspace) { + stage('Run Tests') { + sh """ + . ./${toxEnvName}/bin/activate + tox -e jenkins + """ + } + } + } + } + tasks_2['Initiate SonarQube Analysis'] = { + node(current_node) { + dir(myworkspace) { + stage('Prepare sonar-project.properties') { + sh "echo sonar.projectVersion = \$(cat VERSION) >> ${sonar_properties_path}" + } + stage('Initiate SonarQube analysis') { + withSonarQubeEnv() { + sh "${sqScannerHome}bin/sonar-scanner" + } + } + } + } + } + try { + // run parallel tasks + parallel tasks_1 + parallel tasks_2 + } catch (err) { + currentBuild.result = 'FAILURE' + sendmail('FAILURE') + } +} +// Wait for sonar quality gate +stage("Quality Gate") { + timeout(time: 10, unit: 'MINUTES') { // Just in case something goes wrong, pipeline will be killed after a timeout + def qg = waitForQualityGate() // Reuse taskId previously collected by withSonarQubeEnv + if (qg.status != 'OK') { + echo "Pipeline unstable due to quality gate failure: ${qg.status}" + currentBuild.result = 'UNSTABLE' + sendmail('UNSTABLE') + } + } +} + diff --git a/MANIFEST.in b/MANIFEST.in index 16cb432faab5739f4f278a56b0275f86639aa2a2..baf28d9b5320f52312b25359994996618cd38e36 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,14 +1,11 @@ -include README.txt -recursive-include tests * -recursive-include kvalikirstu2 * -recursive-include docs * -recursive-include "kvalikirstu2/locale" * -include scripts/* -include scripts/docs/* -include *.include -include *.ini +include VERSION +include *.rst include *.txt +include tox.ini +include .pylintrc +include .coveragerc +include tests/* -global-exclude *.py[co] -global-exclude __pycache__ -prune kvalikirstu2/.vscode \ No newline at end of file +recursive-include "tests/test files src" * +recursive-include tests * +recursive-include "kvalikirstu2/locale" * \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 0000000000000000000000000000000000000000..345f8cc0391bff8037eee5caeeff124f0f4de824 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.10 \ No newline at end of file diff --git a/kvalikirstu2/utils.py b/kvalikirstu2/utils.py index e82c4e3ce60544d92770f693aff986cbb45730b5..14c5376c354c75e15a24f9feaccdad44d4a892aa 100644 --- a/kvalikirstu2/utils.py +++ b/kvalikirstu2/utils.py @@ -153,11 +153,10 @@ def json_serialize(obj, path: str): """ logger.debug('Saving %s to path %s', obj, path) - json_string = jsonpickle.encode(obj) + json_string = jsonpickle.encode(obj, make_refs=False) obj = json.loads(json_string) output = json.dumps(obj, indent=4, ensure_ascii=False, sort_keys=True).encode('utf8') create_folder_if_not_exists(os.path.dirname(path)) - with open(path, 'wb') as file_stream: file_stream.write(output) diff --git a/requirements.txt b/requirements.txt index 7fc8b60e9f4cdb823b7973e91a230105ea6f20be..44b7ef120b3adc2d13dfc9087b4015d44f676f42 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,14 @@ -Babel>=2.8.0 -chardet==3.0.4 +Babel==2.13.1 +chardet==5.2 ConfigArgParse==0.14.0 defusedxml==0.5.0 -Jinja2==2.10 -jsonpickle==1.1 -MarkupSafe==1.1.1 -natsort==6.2.0 +Jinja2==3.0.3 +jsonpickle==3.0.2 +MarkupSafe==2.1.3 +natsort==8.4.0 odfpy==1.4.0 -Pillow>=6.0.0 -six>=1.14.0 -wxPython==4.1.1;platform_system=="Windows" +Pillow==10.1.0 +setuptools==58 +six==1.16.0 +wxPython==4.2.1;platform_system=="Windows" pypubsub===4.0.3 \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt index 92507001a0b6b8c260d9a62996c804c75175a834..0fcad4351a49db6aa5a81bba32c991b23ea97938 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,17 +1,3 @@ -Babel==2.6.0 -chardet==3.0.4 -ConfigArgParse==0.14.0 -defusedxml==0.5.0 -Jinja2==2.10 -jsonpickle==1.1 -MarkupSafe==1.1.1 -natsort==6.2.0 -odfpy==1.4.0 -Pillow>=6.0.0 -six>=1.14.0 -wxPython==4.1.1;platform_system=="Windows" -https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ubuntu-16.04/wxPython-4.1.1-cp38-cp38-linux_x86_64.whl; sys_platform == "linux" -pytest<=4.0 -pytest-cov<=2.8 -pyvirtualdisplay==0.2.1 -pypubsub===4.0.3 \ No newline at end of file +pytest==7.4.3 +pytest-cov==4.1.0 +pyvirtualdisplay==3.0 \ No newline at end of file diff --git a/setup.py b/setup.py index 6af4de7343eab71441e43d37a2418fdb615a2304..22996b61dd7da2a7bc91fd0c9e100b04f78e4c4e 100644 --- a/setup.py +++ b/setup.py @@ -2,12 +2,16 @@ # Author(s): Saara Saaninkoski and Jukka Lipsanen # Copyright 2019 Finnish Social Science Data Archive FSD / University of Tampere # Licensed under the EUPL. See LICENCE.txt for full license. - +import os from setuptools import setup, find_packages +this_dir = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(this_dir, 'VERSION'), 'r') as fileobj: + version = fileobj.readline().strip() + setup( name='kvalikirstu2', - version='1.0.10', + version=version, url='', description='A tool for analyzing qualitative studies', author='Jukka Lipsanen and Saara Saaninkoski', @@ -22,9 +26,6 @@ setup( 'pypubsub', 'natsort' ], - tests_require=['pytest'], - setup_requires=['Babel'], - include_package_data=True, entry_points={ 'console_scripts': [ 'python-kvalikirstu2-gui = kvalikirstu2.gui:main', diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000000000000000000000000000000000000..9ee5b18f7d87e0cf6b14855ae99968d4a3b606db --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,9 @@ +# unique project identifier (required) - The project's unique key. Allowed characters are: letters, +# numbers, -, _, . and :, with at least one non-digit. +sonar.projectKey=kvalikirstu2 +# path to source directories (required) - Comma-separated paths to directories containing main source files. +sonar.sources=kvalikirstu2 +# Path to coverage (Cobertura XML) report +sonar.python.coverage.reportPaths=coverage.xml +# Path to pylint report +sonar.python.pylint.reportPath=pylint_report.txt diff --git a/tests/conftest.py b/tests/conftest.py index 49222557e9f71292d0ffb84a2d5287cddfe399a6..4afba12bae6c2203e4c44c807a0c596b94a2b1b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,8 @@ import os import pytest import logging +from kvalikirstu2 import argument_parser + TEST_FILES_SRC = os.path.join(os.path.dirname(__file__), "test files src") TEST_FILES_DST = os.path.join(os.path.dirname(__file__), "test files") VARS = {'display': None} @@ -20,10 +22,11 @@ def setup(request): request.addfinalizer(finalize) setup_rootlogger() - if os.name == "posix" and "DISPLAY" not in os.environ: + if os.name == "posix" and (all(not os.getenv(x) for x in ["DISPLAY", "NO_WX"])): import pyvirtualdisplay VARS['display'] = pyvirtualdisplay.Display() VARS['display'].start() + argument_parser.Settings().set_up_cli_settings([]) def rootlogger_error(*args, **kwargs): diff --git a/tests/test_argument_parser.py b/tests/test_argument_parser.py index 1164368ece5c442d27e63886ea1dc85506a699cd..16a6223c8ccf669eae2c275e55df10c71fd12fff 100644 --- a/tests/test_argument_parser.py +++ b/tests/test_argument_parser.py @@ -82,6 +82,7 @@ class ArgumentParserTest(unittest.TestCase, test_writer.CitReqMixin, test_kvali_ self.assertTrue(mock.called) + @unittest.skipIf(os.getenv('NO_WX'), 'Skipping tests that require wxPython') def test_handler_for_gui(self): settings = argument_parser.Settings() settings.set_up_gui_settings([]) diff --git a/tests/test_backup_select_window.py b/tests/test_backup_select_window.py index 5b1a7d978af3a49b14b092ba98b5a90cb680cb31..5515c7cdc166af4809c8fd36e1cdcb31a51a6813 100644 --- a/tests/test_backup_select_window.py +++ b/tests/test_backup_select_window.py @@ -1,4 +1,9 @@ +# Skip this module if NO_WX in env +import pytest import os +if os.getenv('NO_WX'): + pytest.skip('Skipping module because wxPython is not available', allow_module_level=True) + import unittest import wx from kvalikirstu2 import argument_parser diff --git a/tests/test_converter.py b/tests/test_converter.py index b9e53d829f922e487cb1afa594320cc10dddd966..1d8f92ec069299fb5349ffc63cb2b2ec2c35b9e5 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -119,6 +119,7 @@ class TestConvertEncoding(unittest.TestCase): self.subfolder_conversion_test("qwertyuiopasdfghjklzxcvbnmåäö", 'cp1252', 10) self.subfolder_conversion_test("qwertyuiopasdfghjklzxcvbnmåäö", 'utf-8', 10) + @unittest.skipIf(os.getenv('NO_ODT'), 'Skipping tests that require LibreOffice') def test_libreoffice_conversion_to_odt(self): docx_path = os.path.join(self.temp_path, "data", "test_data1.docx") data_path = os.path.join(self.temp_path, "data") @@ -133,6 +134,7 @@ class TestConvertEncoding(unittest.TestCase): self.assertFalse(os.path.exists(docx_path)) self.assertEqual(expected_content, content) + @unittest.skipIf(os.getenv('NO_ODT'), 'Skipping tests that require LibreOffice') def test_libreoffice_conversion_to_txt(self): docx_path = os.path.join(self.temp_path, "data", "test_data1.docx") data_path = os.path.join(self.temp_path, "data") @@ -148,6 +150,7 @@ class TestConvertEncoding(unittest.TestCase): self.assertFalse(os.path.exists(docx_path)) self.assertEqual(lines1, lines2) + @unittest.skipIf(os.getenv('NO_ODT'), 'Skipping tests that require LibreOffice') def test_timeout_low_throws(self): data_path = os.path.join(self.temp_path, "data") with self.assertRaises(subprocess.TimeoutExpired): diff --git a/tests/test_csv_generator.py b/tests/test_csv_generator.py index 1fb5c075e2b87c9285df7c139b73ff514e6cbb7c..f27f8bbdf7c5960b253494db96932a87c43e10db 100644 --- a/tests/test_csv_generator.py +++ b/tests/test_csv_generator.py @@ -24,7 +24,7 @@ class TestCSVGenerator(unittest.TestCase): self.interface = DummyInterface() def test_generator(self): - reader = study_reader.StudyReader('FSDxxxx', True, data_file=get_testpath(), study_path=get_testdir()) + reader = study_reader.StudyReader('FSDxxxx', 'Title', data_file=get_testpath(), study_path=get_testdir()) header_info = header_scanner.get_header_info_for_study(get_testpath()) study = reader.get_study() csv_path = os.path.join(get_testdir(), 'daFxxxx.csv') diff --git a/tests/test_gui.py b/tests/test_gui.py index bb0d9964e071ec01b75908f5a46aab1056f78716..69abf12a6a6cfeae4f3ade836de77ab0f8886235 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1,5 +1,11 @@ """ Tests for gui classes. """ +# Skip this module if NO_WX in env +import pytest +import os +if os.getenv('NO_WX'): + pytest.skip('Skipping module because wxPython is not available', allow_module_level=True) + import os import unittest import wx diff --git a/tests/test_gui_model.py b/tests/test_gui_model.py index bf762542a8df0743387d198e04fa65c834ca6d4f..032409a5901a16726d0b084cfd2f159a04f52d89 100644 --- a/tests/test_gui_model.py +++ b/tests/test_gui_model.py @@ -68,6 +68,7 @@ class TestGUIModel(unittest.TestCase): self.model.save_changes_to_daf() self.assertFalse(self.model.check_unsaved_changes()) + @unittest.skipIf(os.getenv('NO_ODT'), 'Skipping tests that require Libreoffice') def test_conversion(self): self.model.convert_to_odt() test_data_path = os.path.join(self.temp_path, "data", "to_convert.odt") diff --git a/tests/test_gui_utils.py b/tests/test_gui_utils.py index bc69af5c912d2f139a43c5e369afcd7e75a06e8a..a9bbcb5f05f1b2f1197729fa4d6c113c3a5ccdb3 100644 --- a/tests/test_gui_utils.py +++ b/tests/test_gui_utils.py @@ -1,3 +1,9 @@ +# Skip this module if NO_WX in env +import pytest +import os +if os.getenv('NO_WX'): + pytest.skip('Skipping module because wxPython is not available', allow_module_level=True) + import unittest import wx from kvalikirstu2 import argument_parser, gui_utils diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 8e97c2b8a047bf7edfe1d6c1d2b55a169e8a0c0b..da69036d3aa6cb5dbc2988f48a9c16ee6da52263 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -11,7 +11,7 @@ from kvalikirstu2 import argument_parser @pytest.fixture(name='testdir') def get_testdir(): - return os.path.join(os.path.dirname(__file__), "test files", "valid") + return os.path.join(os.path.dirname(__file__), "test files") @pytest.fixture(name='settings') @@ -34,7 +34,7 @@ class TestSerialization(unittest.TestCase): @pytest.fixture(autouse=True) def init_reader_valid(self, testdir, test_id, store_args): store_args - self.reader = study_reader.StudyReader(testdir, test_id, "", study_path=testdir) + self.reader = study_reader.StudyReader(study_id=test_id, study_path=testdir, study_title="", data_folder_name="data") """For testing the serialization in the utils module""" def test_study_reader(self): @@ -47,7 +47,7 @@ class TestSerialization(unittest.TestCase): loaded_study.write_to_file() # Load a copy of the study from file - copy = study.Study(self.reader.study_id, self.reader.study_path, "") + copy = study.Study(study_id=self.reader.study_id, study_path=self.reader.study_path, study_title="") copy.load_from_file() os.unlink(loaded_study.get_full_path()) diff --git a/tests/test_tmp_edit_window.py b/tests/test_tmp_edit_window.py index 987ec00fc6faa4e75033fd2b15f80afc2bf881dd..e53291e0dacc3e5659c5c967c998eb148a0ebd83 100644 --- a/tests/test_tmp_edit_window.py +++ b/tests/test_tmp_edit_window.py @@ -1,3 +1,9 @@ +# Skip this module if NO_WX in env +import pytest +import os +if os.getenv('NO_WX'): + pytest.skip('Skipping module because wxPython is not available', allow_module_level=True) + import unittest import os import tempfile diff --git a/tests/test_warning_handler.py b/tests/test_warning_handler.py index 9976ffaa72b971bb0d56c4a381f0149eba9c4dab..7dcbfe32e26d3bce1463ce0b5fdd051f42b66aa7 100644 --- a/tests/test_warning_handler.py +++ b/tests/test_warning_handler.py @@ -1,4 +1,11 @@ +# Skip this module if NO_WX in env +import pytest +import os +if os.getenv('NO_WX'): + pytest.skip('Skipping module because wxPython is not available', allow_module_level=True) + import logging +from unittest import mock import unittest from kvalikirstu2 import argument_parser @@ -9,22 +16,22 @@ class TestWarningHandler(unittest.TestCase): settings.set_up_gui_settings([]) self.logger = logging.getLogger('kvalikirstu2.testlogger') - @unittest.mock.patch('wx.MessageBox') + @mock.patch('wx.MessageBox') def test_logger_opens_warning_message(self, mock): self.logger.warning('test') self.assertTrue(mock.called) - @unittest.mock.patch('wx.MessageBox') + @mock.patch('wx.MessageBox') def test_logger_opens_error_message(self, mock): self.logger.error('test') self.assertFalse(mock.called) - @unittest.mock.patch('wx.MessageBox') + @mock.patch('wx.MessageBox') def test_info(self, mock): self.logger.info('test') self.assertFalse(mock.called) - @unittest.mock.patch('wx.MessageBox') + @mock.patch('wx.MessageBox') def test_debug(self, mock): self.logger.info('test') self.assertFalse(mock.called) diff --git a/tox.ini b/tox.ini index b23ea8bee7a5ad8af16000750cf76128cc855c79..a8af8e8763cda11ab06dcb8e026a3772ad8f2c68 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,31 @@ +[tox] +envlist = py38, py310, py311 + [testenv] -deps = -rrequirements_test.txt +deps = -rrequirements.txt + -rrequirements_test.txt +commands = pytest + +[testenv:with-coverage] +deps = {[testenv]deps} + coverage==7.2.3 +commands = pytest --cov=kvalikirstu2 + coverage xml + coverage report + +[testenv:jenkins] +setenv = + NO_WX = 1 + NO_ODT = 1 +deps = {[testenv]deps} +commands = {[testenv]commands} -commands = pytest --cov --cov-append --cov-report=term-missing {posargs} - pytest --cov --cov-report xml {posargs} +[testenv:jenkins-with-coverage] +setenv = + NO_WX = 1 + NO_ODT = 1 +deps = {[testenv:with-coverage]deps} +commands = {[testenv:with-coverage]commands} [flake8] max-line-length = 120 \ No newline at end of file