Skip to content

check for correct version values when parsing easystack file #3693

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 22, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 41 additions & 23 deletions easybuild/framework/easystack.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@

:author: Denis Kristak (Inuits)
:author: Pavel Grochal (Inuits)
:author: Kenneth Hoste (HPC-UGent)
"""

from easybuild.base import fancylogger
from easybuild.tools.build_log import EasyBuildError
from easybuild.tools.filetools import read_file
from easybuild.tools.module_naming_scheme.utilities import det_full_ec_version
from easybuild.tools.py2vs3 import string_type
from easybuild.tools.utilities import only_if_module_is_available
try:
import yaml
Expand All @@ -41,6 +42,25 @@
_log = fancylogger.getLogger('easystack', fname=False)


def check_version(value, context):
"""
Check whether specified value obtained from a YAML file in specified context represents a valid version.
The value must be a string value (not a float or an int).
"""
if not isinstance(value, string_type):
error_msg = '\n'.join([
"Value %(value)s (of type %(type)s) obtained for %(context)s does not represent a valid version!",
"Make sure to wrap the value in single quotes (like '%(value)s') to avoid that it is interpreted "
"by the YAML parser as a non-string value.",
])
format_info = {
'context': context,
'type': type(value),
'value': value,
}
raise EasyBuildError(error_msg % format_info)


class EasyStack(object):
"""One class instance per easystack. General options + list of all SoftwareSpecs instances"""

Expand Down Expand Up @@ -90,7 +110,12 @@ class EasyStackParser(object):
def parse(filepath):
"""Parses YAML file and assigns obtained values to SW config instances as well as general config instance"""
yaml_txt = read_file(filepath)
easystack_raw = yaml.safe_load(yaml_txt)

try:
easystack_raw = yaml.safe_load(yaml_txt)
except yaml.YAMLError as err:
raise EasyBuildError("Failed to parse %s: %s" % (filepath, err))

easystack = EasyStack()

try:
Expand Down Expand Up @@ -123,6 +148,8 @@ def parse(filepath):
raise EasyBuildError("Incorrect toolchain specification for '%s' in %s, too many parts: %s",
name, filepath, toolchain_parts)

check_version(toolchain_version, "software %s (with %s toolchain)" % (name, toolchain_name))

try:
# if version string containts asterisk or labels, raise error (asterisks not supported)
versions = toolchains[toolchain]['versions']
Expand All @@ -146,6 +173,7 @@ def parse(filepath):
# ========================================================================
if isinstance(versions, dict):
for version in versions:
check_version(version, "%s (with %s toolchain)" % (name, toolchain_name))
if versions[version] is not None:
version_spec = versions[version]
if 'versionsuffix' in version_spec:
Expand All @@ -172,35 +200,25 @@ def parse(filepath):
easystack.software_list.append(sw)
continue

# is format read as a list of versions?
# ========================================================================
# versions:
# [2.24, 2.51]
# ========================================================================
elif isinstance(versions, list):
versions_list = versions
elif isinstance(versions, (list, tuple)):
pass

# format = multiple lines without ':' (read as a string)?
# ========================================================================
# multiple lines without ':' is read as a single string; example:
# versions:
# 2.24
# 2.51
# ========================================================================
elif isinstance(versions, str):
versions_list = str(versions).split()
elif isinstance(versions, string_type):
versions = versions.split()

# format read as float (containing one version only)?
# ========================================================================
# versions:
# 2.24
# ========================================================================
elif isinstance(versions, float):
versions_list = [str(versions)]
# single values like 2.24 should be wrapped in a list
else:
versions = [versions]

# if no version is a dictionary, versionsuffix isn't specified
# if version is not a dictionary, versionsuffix is not specified
versionsuffix = ''

for version in versions_list:
for version in versions:
check_version(version, "%s (with %s toolchain)" % (name, toolchain_name))
sw = SoftwareSpecs(
name=name, version=version, versionsuffix=versionsuffix,
toolchain_name=toolchain_name, toolchain_version=toolchain_version)
Expand Down
195 changes: 195 additions & 0 deletions test/framework/easystack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# #
# Copyright 2013-2021 Ghent University
#
# This file is part of EasyBuild,
# originally created by the HPC team of Ghent University (http://ugent.be/hpc/en),
# with support of Ghent University (http://ugent.be/hpc),
# the Flemish Supercomputer Centre (VSC) (https://www.vscentrum.be),
# Flemish Research Foundation (FWO) (http://www.fwo.be/en)
# and the Department of Economy, Science and Innovation (EWI) (http://www.ewi-vlaanderen.be/en).
#
# https://github.com/easybuilders/easybuild
#
# EasyBuild is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation v2.
#
# EasyBuild is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with EasyBuild. If not, see <http://www.gnu.org/licenses/>.
# #
"""
Unit tests for easystack files

@author: Denis Kristak (Inuits)
@author: Kenneth Hoste (Ghent University)
"""
import os
import sys
from unittest import TextTestRunner

import easybuild.tools.build_log
from easybuild.framework.easystack import check_version, parse_easystack
from easybuild.tools.build_log import EasyBuildError
from easybuild.tools.filetools import write_file
from test.framework.utilities import EnhancedTestCase, TestLoaderFiltered


class EasyStackTest(EnhancedTestCase):
"""Testcases for easystack files."""

logfile = None

def setUp(self):
"""Set up test."""
super(EasyStackTest, self).setUp()
self.orig_experimental = easybuild.tools.build_log.EXPERIMENTAL
# easystack files are an experimental feature
easybuild.tools.build_log.EXPERIMENTAL = True

def tearDown(self):
"""Clean up after test."""
easybuild.tools.build_log.EXPERIMENTAL = self.orig_experimental
super(EasyStackTest, self).tearDown()

def test_parse_fail(self):
"""Test for clean error when easystack file fails to parse."""
test_yml = os.path.join(self.test_prefix, 'test.yml')
write_file(test_yml, 'software: %s')
error_pattern = "Failed to parse .*/test.yml: while scanning for the next token"
self.assertErrorRegex(EasyBuildError, error_pattern, parse_easystack, test_yml)

def test_easystack_wrong_structure(self):
"""Test for --easystack <easystack.yaml> when yaml easystack has wrong structure"""
topdir = os.path.dirname(os.path.abspath(__file__))
test_easystack = os.path.join(topdir, 'easystacks', 'test_easystack_wrong_structure.yaml')

expected_err = r"[\S\s]*An error occurred when interpreting the data for software Bioconductor:"
expected_err += r"( 'float' object is not subscriptable[\S\s]*"
expected_err += r"| 'float' object is unsubscriptable"
expected_err += r"| 'float' object has no attribute '__getitem__'[\S\s]*)"
self.assertErrorRegex(EasyBuildError, expected_err, parse_easystack, test_easystack)

def test_easystack_asterisk(self):
"""Test for --easystack <easystack.yaml> when yaml easystack contains asterisk (wildcard)"""
topdir = os.path.dirname(os.path.abspath(__file__))
test_easystack = os.path.join(topdir, 'easystacks', 'test_easystack_asterisk.yaml')

expected_err = "EasyStack specifications of 'binutils' in .*/test_easystack_asterisk.yaml contain asterisk. "
expected_err += "Wildcard feature is not supported yet."

self.assertErrorRegex(EasyBuildError, expected_err, parse_easystack, test_easystack)

def test_easystack_labels(self):
topdir = os.path.dirname(os.path.abspath(__file__))
test_easystack = os.path.join(topdir, 'easystacks', 'test_easystack_labels.yaml')

error_msg = "EasyStack specifications of 'binutils' in .*/test_easystack_labels.yaml contain labels. "
error_msg += "Labels aren't supported yet."
self.assertErrorRegex(EasyBuildError, error_msg, parse_easystack, test_easystack)

def test_check_version(self):
"""Test check_version function."""
check_version('1.2.3', None)
check_version('1.2', None)
check_version('3.50', None)
check_version('100', None)

context = "<some context>"
for version in (1.2, 100, None):
error_pattern = r"Value .* \(of type .*\) obtained for <some context> does not represent a valid version!"
self.assertErrorRegex(EasyBuildError, error_pattern, check_version, version, context)

def test_easystack_versions(self):
"""Test handling of versions in easystack files."""

test_easystack = os.path.join(self.test_prefix, 'test.yml')
tmpl_easystack_txt = '\n'.join([
"software:",
" foo:",
" toolchains:",
" SYSTEM:",
" versions:",
])

# normal versions, which are not treated special by YAML: no single quotes needed
versions = ('1.2.3', '1.2.30', '2021a', '1.2.3')
for version in versions:
write_file(test_easystack, tmpl_easystack_txt + ' ' + version)
ec_fns, _ = parse_easystack(test_easystack)
self.assertEqual(ec_fns, ['foo-%s.eb' % version])

# multiple versions as a list
test_easystack_txt = tmpl_easystack_txt + " [1.2.3, 3.2.1]"
write_file(test_easystack, test_easystack_txt)
ec_fns, _ = parse_easystack(test_easystack)
expected = ['foo-1.2.3.eb', 'foo-3.2.1.eb']
self.assertEqual(sorted(ec_fns), sorted(expected))

# multiple versions listed with more info
test_easystack_txt = '\n'.join([
tmpl_easystack_txt,
" 1.2.3:",
" 2021a:",
" 3.2.1:",
" versionsuffix: -foo",
])
write_file(test_easystack, test_easystack_txt)
ec_fns, _ = parse_easystack(test_easystack)
expected = ['foo-1.2.3.eb', 'foo-2021a.eb', 'foo-3.2.1-foo.eb']
self.assertEqual(sorted(ec_fns), sorted(expected))

# versions that get interpreted by YAML as float or int, single quotes required
for version in ('1.2', '123', '3.50', '100', '2.44_01'):
error_pattern = r"Value .* \(of type .*\) obtained for foo \(with system toolchain\) "
error_pattern += r"does not represent a valid version\!"

write_file(test_easystack, tmpl_easystack_txt + ' ' + version)
self.assertErrorRegex(EasyBuildError, error_pattern, parse_easystack, test_easystack)

# all is fine when wrapping the value in single quotes
write_file(test_easystack, tmpl_easystack_txt + " '" + version + "'")
ec_fns, _ = parse_easystack(test_easystack)
self.assertEqual(ec_fns, ['foo-%s.eb' % version])

# one rotten apple in the basket is enough
test_easystack_txt = tmpl_easystack_txt + " [1.2.3, %s, 3.2.1]" % version
write_file(test_easystack, test_easystack_txt)
self.assertErrorRegex(EasyBuildError, error_pattern, parse_easystack, test_easystack)

test_easystack_txt = '\n'.join([
tmpl_easystack_txt,
" 1.2.3:",
" %s:" % version,
" 3.2.1:",
" versionsuffix: -foo",
])
write_file(test_easystack, test_easystack_txt)
self.assertErrorRegex(EasyBuildError, error_pattern, parse_easystack, test_easystack)

# single quotes to the rescue!
test_easystack_txt = '\n'.join([
tmpl_easystack_txt,
" 1.2.3:",
" '%s':" % version,
" 3.2.1:",
" versionsuffix: -foo",
])
write_file(test_easystack, test_easystack_txt)
ec_fns, _ = parse_easystack(test_easystack)
expected = ['foo-1.2.3.eb', 'foo-%s.eb' % version, 'foo-3.2.1-foo.eb']
self.assertEqual(sorted(ec_fns), sorted(expected))


def suite():
""" returns all the testcases in this module """
return TestLoaderFiltered().loadTestsFromTestCase(EasyStackTest, sys.argv[1:])


if __name__ == '__main__':
res = TextTestRunner(verbosity=1).run(suite())
sys.exit(len(res.failures))
6 changes: 3 additions & 3 deletions test/framework/easystacks/test_easystack_basic.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ software:
toolchains:
GCCcore-4.9.3:
versions:
2.25:
2.26:
'2.25':
'2.26':
foss:
toolchains:
SYSTEM:
Expand All @@ -13,5 +13,5 @@ software:
toolchains:
gompi-2018a:
versions:
0.0:
'0.0':
versionsuffix: '-test'
2 changes: 1 addition & 1 deletion test/framework/easystacks/test_easystack_labels.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ software:
toolchains:
GCCcore-4.9.3:
versions:
3.11:
'3.11':
exclude-labels: arch:aarch64
34 changes: 0 additions & 34 deletions test/framework/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
import easybuild.tools.toolchain
from easybuild.base import fancylogger
from easybuild.framework.easyblock import EasyBlock
from easybuild.framework.easystack import parse_easystack
from easybuild.framework.easyconfig import BUILD, CUSTOM, DEPENDENCIES, EXTENSIONS, FILEMANAGEMENT, LICENSE
from easybuild.framework.easyconfig import MANDATORY, MODULES, OTHER, TOOLCHAIN
from easybuild.framework.easyconfig.easyconfig import EasyConfig, get_easyblock_class, robot_find_easyconfig
Expand Down Expand Up @@ -6033,39 +6032,6 @@ def test_easystack_basic(self):
regex = re.compile(pattern)
self.assertTrue(regex.search(stdout), "Pattern '%s' should be found in: %s" % (regex.pattern, stdout))

def test_easystack_wrong_structure(self):
"""Test for --easystack <easystack.yaml> when yaml easystack has wrong structure"""
easybuild.tools.build_log.EXPERIMENTAL = True
topdir = os.path.dirname(os.path.abspath(__file__))
toy_easystack = os.path.join(topdir, 'easystacks', 'test_easystack_wrong_structure.yaml')

expected_err = r"[\S\s]*An error occurred when interpreting the data for software Bioconductor:"
expected_err += r"( 'float' object is not subscriptable[\S\s]*"
expected_err += r"| 'float' object is unsubscriptable"
expected_err += r"| 'float' object has no attribute '__getitem__'[\S\s]*)"
self.assertErrorRegex(EasyBuildError, expected_err, parse_easystack, toy_easystack)

def test_easystack_asterisk(self):
"""Test for --easystack <easystack.yaml> when yaml easystack contains asterisk (wildcard)"""
easybuild.tools.build_log.EXPERIMENTAL = True
topdir = os.path.dirname(os.path.abspath(__file__))
toy_easystack = os.path.join(topdir, 'easystacks', 'test_easystack_asterisk.yaml')

expected_err = "EasyStack specifications of 'binutils' in .*/test_easystack_asterisk.yaml contain asterisk. "
expected_err += "Wildcard feature is not supported yet."

self.assertErrorRegex(EasyBuildError, expected_err, parse_easystack, toy_easystack)

def test_easystack_labels(self):
"""Test for --easystack <easystack.yaml> when yaml easystack contains exclude-labels / include-labels"""
easybuild.tools.build_log.EXPERIMENTAL = True
topdir = os.path.dirname(os.path.abspath(__file__))
toy_easystack = os.path.join(topdir, 'easystacks', 'test_easystack_labels.yaml')

error_msg = "EasyStack specifications of 'binutils' in .*/test_easystack_labels.yaml contain labels. "
error_msg += "Labels aren't supported yet."
self.assertErrorRegex(EasyBuildError, error_msg, parse_easystack, toy_easystack)


def suite():
""" returns all the testcases in this module """
Expand Down
Loading