gialint 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gialint-0.1.0/LICENSE +21 -0
- gialint-0.1.0/PKG-INFO +22 -0
- gialint-0.1.0/README.md +13 -0
- gialint-0.1.0/gialint/__init__.py +21 -0
- gialint-0.1.0/gialint/__main__.py +101 -0
- gialint-0.1.0/gialint/_checks/__init__.py +0 -0
- gialint-0.1.0/gialint/_checks/gia101.py +8 -0
- gialint-0.1.0/gialint/_checks/gia102.py +12 -0
- gialint-0.1.0/gialint/_checks/gia201.py +20 -0
- gialint-0.1.0/gialint/_checks/gia202.py +45 -0
- gialint-0.1.0/gialint/_checks/gia203.py +36 -0
- gialint-0.1.0/gialint/_checks/gia204.py +99 -0
- gialint-0.1.0/gialint/_context.py +15 -0
- gialint-0.1.0/gialint/codes.py +9 -0
- gialint-0.1.0/gialint/utils.py +285 -0
- gialint-0.1.0/gialint/version.py +5 -0
- gialint-0.1.0/gialint.egg-info/PKG-INFO +22 -0
- gialint-0.1.0/gialint.egg-info/SOURCES.txt +24 -0
- gialint-0.1.0/gialint.egg-info/dependency_links.txt +1 -0
- gialint-0.1.0/gialint.egg-info/requires.txt +4 -0
- gialint-0.1.0/gialint.egg-info/top_level.txt +1 -0
- gialint-0.1.0/setup.cfg +4 -0
- gialint-0.1.0/setup.py +26 -0
- gialint-0.1.0/tests/test_checks.py +53 -0
- gialint-0.1.0/tests/test_system.py +47 -0
- gialint-0.1.0/tests/test_utils.py +271 -0
gialint-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BMCV
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
gialint-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gialint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Linter for tool wrappers in Galaxy Image Analysis
|
|
5
|
+
Home-page: https://kostrykin.com
|
|
6
|
+
Author: Leonid Kostrykin
|
|
7
|
+
Author-email: leonid.kostrykin@bioquant.uni-heidelberg.de
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: galaxy-util>=25.0
|
|
12
|
+
Requires-Dist: lxml>=6.0.2
|
|
13
|
+
Requires-Dist: Cheetah3>=3.2.6.post1
|
|
14
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
15
|
+
Dynamic: author
|
|
16
|
+
Dynamic: author-email
|
|
17
|
+
Dynamic: home-page
|
|
18
|
+
Dynamic: license
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
Dynamic: requires-dist
|
|
21
|
+
Dynamic: requires-python
|
|
22
|
+
Dynamic: summary
|
gialint-0.1.0/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# galaxy-image-analysis-lint
|
|
2
|
+
|
|
3
|
+
[](https://github.com/BMCV/galaxy-image-analysis-lint/actions/workflows/testsuite.yml)
|
|
4
|
+
|
|
5
|
+
Example usage:
|
|
6
|
+
```bash
|
|
7
|
+
python -m gialint --tool_path ../galaxy-image-analysis/tools/
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Run tests:
|
|
11
|
+
```bash
|
|
12
|
+
python -m unittest
|
|
13
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from . import codes
|
|
2
|
+
from .version import __version__
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
'__version__',
|
|
6
|
+
'codes',
|
|
7
|
+
'list_codes',
|
|
8
|
+
'check',
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def list_codes():
|
|
13
|
+
for attr in dir(codes):
|
|
14
|
+
if attr.startswith('GIA'):
|
|
15
|
+
yield attr
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check(code, tool_xml_root):
|
|
19
|
+
check_name = f'_checks.{code.lower()}'
|
|
20
|
+
check_module = __import__(check_name, globals(), locals(), fromlist=['*'], level=1)
|
|
21
|
+
yield from check_module.check(tool_xml_root)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import pathlib
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
from galaxy.util import xml_macros
|
|
7
|
+
|
|
8
|
+
from . import (
|
|
9
|
+
check,
|
|
10
|
+
codes,
|
|
11
|
+
list_codes,
|
|
12
|
+
)
|
|
13
|
+
from ._context import Context
|
|
14
|
+
|
|
15
|
+
# Monkey-patch `xml_macros` to not remove comments:
|
|
16
|
+
xml_macros.raw_xml_tree = lambda path: (
|
|
17
|
+
xml_macros.parse_xml(path, strip_whitespace=False, remove_comments=False)
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
gialint_root_path = pathlib.Path(__file__).parent
|
|
21
|
+
|
|
22
|
+
parser = argparse.ArgumentParser()
|
|
23
|
+
parser.add_argument('--tool_path', required=False, type=str)
|
|
24
|
+
parser.add_argument('--ignore', action='append', type=str, default=list())
|
|
25
|
+
parser.add_argument('--details_indent', type=int, default=4)
|
|
26
|
+
parser.add_argument('--config', type=str, default='.gialint.yml')
|
|
27
|
+
args = parser.parse_args()
|
|
28
|
+
|
|
29
|
+
# Load config (empty if not specified)
|
|
30
|
+
config = dict()
|
|
31
|
+
if args.config:
|
|
32
|
+
config_filepath = pathlib.Path(args.config)
|
|
33
|
+
if config_filepath.is_file():
|
|
34
|
+
with config_filepath.open('r') as fp:
|
|
35
|
+
config = yaml.safe_load(fp)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def list_tool_xml(path):
|
|
39
|
+
if path.is_file():
|
|
40
|
+
yield path
|
|
41
|
+
else:
|
|
42
|
+
for xml_path in path.glob('**/*.xml'):
|
|
43
|
+
yield xml_path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def list_violations(tool_xml_path, ignore_codes):
|
|
47
|
+
for code in list_codes():
|
|
48
|
+
|
|
49
|
+
# Skip the check if it was passed via `--ignore`
|
|
50
|
+
if code in ignore_codes:
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
# Skip the check if it was listed in the `--config` file
|
|
54
|
+
skip = False
|
|
55
|
+
for pattern, c in config.items():
|
|
56
|
+
if pathlib.Path(tool_xml_path).match(pattern) and (
|
|
57
|
+
code in filter(lambda s: s.strip(), c.get('ignore', list()))
|
|
58
|
+
):
|
|
59
|
+
skip = True
|
|
60
|
+
break
|
|
61
|
+
if skip:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
# Run the checks
|
|
65
|
+
for info in check(code, tree.getroot()):
|
|
66
|
+
if isinstance(info, int):
|
|
67
|
+
info = dict(line=info)
|
|
68
|
+
yield Context(code, getattr(codes, code), tool_xml_path, **info)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
working_path = pathlib.Path(args.tool_path) or pathlib.Path.cwd()
|
|
72
|
+
violations_count = 0
|
|
73
|
+
for tool_xml_path in list_tool_xml(working_path):
|
|
74
|
+
tree = xml_macros.load(tool_xml_path)
|
|
75
|
+
if tree.getroot().tag == 'tool':
|
|
76
|
+
|
|
77
|
+
sys.stdout.write(f'Linting {tool_xml_path}... ')
|
|
78
|
+
sys.stdout.flush()
|
|
79
|
+
violations = list(list_violations(tool_xml_path, args.ignore))
|
|
80
|
+
|
|
81
|
+
if violations:
|
|
82
|
+
print('FAILED')
|
|
83
|
+
print()
|
|
84
|
+
for context in violations:
|
|
85
|
+
print(str(context), file=sys.stderr)
|
|
86
|
+
if context.details:
|
|
87
|
+
print(
|
|
88
|
+
'\n'.join(
|
|
89
|
+
(
|
|
90
|
+
' ' * args.details_indent + line
|
|
91
|
+
for line in str(context.details).splitlines()
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
file=sys.stderr,
|
|
95
|
+
)
|
|
96
|
+
print()
|
|
97
|
+
else:
|
|
98
|
+
print('OK')
|
|
99
|
+
violations_count += len(violations)
|
|
100
|
+
|
|
101
|
+
exit(violations_count)
|
|
File without changes
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
def check(tool_xml_root):
|
|
2
|
+
for param in tool_xml_root.findall(".//inputs//param[@type='data']"):
|
|
3
|
+
formats = [fmt.strip().lower() for fmt in param.get('format', '').split(',')]
|
|
4
|
+
if (
|
|
5
|
+
('tabular' in formats) != ('tsv' in formats) or
|
|
6
|
+
('csv' in formats and 'tsv' not in formats)
|
|
7
|
+
):
|
|
8
|
+
yield param.sourceline
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
def check(tool_xml_root):
|
|
2
|
+
for path in (
|
|
3
|
+
'inputs//param[@type="data"]',
|
|
4
|
+
'outputs/data',
|
|
5
|
+
):
|
|
6
|
+
for param in tool_xml_root.findall(f'.//{path}'):
|
|
7
|
+
formats = [fmt.strip().lower() for fmt in param.get('format', '').split(',')]
|
|
8
|
+
if 'tif' in formats or param.get('from_work_dir', '').strip().lower().endswith('.tif'):
|
|
9
|
+
yield param.sourceline
|
|
10
|
+
for param in tool_xml_root.findall('.//tests/test//param'):
|
|
11
|
+
if param.get('ftype', '').strip().lower() == 'tif' or param.get('value', '').strip().lower().endswith('.tif'):
|
|
12
|
+
yield param.sourceline
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from ..utils import get_full_name
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def check(tool_xml_root):
|
|
5
|
+
commands = tool_xml_root.findall('./command')
|
|
6
|
+
command = commands[0].text if commands else None
|
|
7
|
+
for param in tool_xml_root.findall('.//inputs//param[@type="data"]'):
|
|
8
|
+
formats = [fmt.strip().lower() for fmt in param.get('format', '').split(',')]
|
|
9
|
+
if 'zarr' in formats or 'ome.zarr' in formats:
|
|
10
|
+
full_param_name = get_full_name(param)
|
|
11
|
+
if command is None or not all(
|
|
12
|
+
(
|
|
13
|
+
token in command
|
|
14
|
+
for token in (
|
|
15
|
+
f'${full_param_name}.extension',
|
|
16
|
+
f'${full_param_name}.extra_files_path/${full_param_name}.metadata.store_root',
|
|
17
|
+
)
|
|
18
|
+
)
|
|
19
|
+
):
|
|
20
|
+
yield param.sourceline
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from Cheetah import NameMapper
|
|
2
|
+
from Cheetah.Parser import ParseError
|
|
3
|
+
from Cheetah.Template import Template
|
|
4
|
+
|
|
5
|
+
from ..utils import (
|
|
6
|
+
flat_dict_to_nested,
|
|
7
|
+
get_base_namespace,
|
|
8
|
+
get_test_inputs,
|
|
9
|
+
list_tests,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check(tool_xml_root):
|
|
14
|
+
base_namespace = get_base_namespace(tool_xml_root)
|
|
15
|
+
for path in (
|
|
16
|
+
'command',
|
|
17
|
+
'configfiles/configfile',
|
|
18
|
+
):
|
|
19
|
+
for template in tool_xml_root.findall(f'./{path}'):
|
|
20
|
+
|
|
21
|
+
# Test build the template
|
|
22
|
+
try:
|
|
23
|
+
Template(template.text, searchList=dict())
|
|
24
|
+
except ParseError as error:
|
|
25
|
+
yield dict(
|
|
26
|
+
line=template.sourceline,
|
|
27
|
+
details=error,
|
|
28
|
+
)
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
# Build the template for each test (with the corresponding namespace)
|
|
32
|
+
if (inputs_xml_list := tool_xml_root.findall(f'./inputs')):
|
|
33
|
+
inputs_xml = inputs_xml_list[0]
|
|
34
|
+
for test_num, test_xml in enumerate(list_tests(tool_xml_root), start=1):
|
|
35
|
+
namespace = base_namespace | flat_dict_to_nested(get_test_inputs(inputs_xml, test_xml))
|
|
36
|
+
try:
|
|
37
|
+
str(Template(template.text, searchList=namespace))
|
|
38
|
+
except (
|
|
39
|
+
NameMapper.NotFound,
|
|
40
|
+
TypeError,
|
|
41
|
+
) as error:
|
|
42
|
+
yield dict(
|
|
43
|
+
line=template.sourceline,
|
|
44
|
+
details=f'{error} from test {test_num} (line {test_xml.sourceline})',
|
|
45
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import textwrap
|
|
3
|
+
|
|
4
|
+
from Cheetah import NameMapper
|
|
5
|
+
from Cheetah.Parser import ParseError
|
|
6
|
+
from Cheetah.Template import Template
|
|
7
|
+
|
|
8
|
+
from ..utils import (
|
|
9
|
+
flat_dict_to_nested,
|
|
10
|
+
get_base_namespace,
|
|
11
|
+
get_test_inputs,
|
|
12
|
+
list_tests,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check(tool_xml_root):
|
|
17
|
+
base_namespace = get_base_namespace(tool_xml_root)
|
|
18
|
+
if (inputs_xml_list := tool_xml_root.findall(f'./inputs')):
|
|
19
|
+
inputs_xml = inputs_xml_list[0]
|
|
20
|
+
for test_num, test_xml in enumerate(list_tests(tool_xml_root), start=1):
|
|
21
|
+
for template in tool_xml_root.findall(f'./configfiles/configfile'):
|
|
22
|
+
namespace = base_namespace | flat_dict_to_nested(get_test_inputs(inputs_xml, test_xml))
|
|
23
|
+
try:
|
|
24
|
+
s = str(Template(template.text, searchList=namespace))
|
|
25
|
+
try:
|
|
26
|
+
json.loads(s)
|
|
27
|
+
except json.JSONDecodeError:
|
|
28
|
+
yield dict(
|
|
29
|
+
line=template.sourceline,
|
|
30
|
+
details=(
|
|
31
|
+
f'from test {test_num} (line {test_xml.sourceline}):\n' +
|
|
32
|
+
textwrap.dedent(s).strip()
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
except (ParseError, NameMapper.NotFound):
|
|
36
|
+
pass # parse errors are handled by a dedicated check
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
import pathlib
|
|
3
|
+
import textwrap
|
|
4
|
+
|
|
5
|
+
from Cheetah import NameMapper
|
|
6
|
+
from Cheetah.Parser import ParseError
|
|
7
|
+
from Cheetah.Template import Template
|
|
8
|
+
|
|
9
|
+
from ..utils import (
|
|
10
|
+
flat_dict_to_nested,
|
|
11
|
+
get_base_namespace,
|
|
12
|
+
get_test_inputs,
|
|
13
|
+
list_tests,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
prefix = pathlib.Path(__file__).stem.upper() + ':' # GIA204:
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _xpath_or_none(root, xpath: str):
|
|
20
|
+
if (result := root.xpath(xpath)):
|
|
21
|
+
return result[0]
|
|
22
|
+
else:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _list_nonempty_lines(s: str) -> list[str]:
|
|
27
|
+
return list(
|
|
28
|
+
filter(
|
|
29
|
+
lambda line: len(line.strip()) > 0,
|
|
30
|
+
s.splitlines(),
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check(tool_xml_root):
|
|
36
|
+
templates = {
|
|
37
|
+
template.attrib.get('name', ''): template
|
|
38
|
+
for template in tool_xml_root.xpath('./command | ./configfiles/configfile')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Validate that annotations correspond to valid templates
|
|
42
|
+
for test_xml in list_tests(tool_xml_root):
|
|
43
|
+
for comment in test_xml.xpath(
|
|
44
|
+
f'./comment()[starts-with(normalize-space(.), "{prefix}")]',
|
|
45
|
+
):
|
|
46
|
+
comment_text = textwrap.dedent(comment.text).strip()
|
|
47
|
+
header = _list_nonempty_lines(comment_text)
|
|
48
|
+
|
|
49
|
+
# Do not strip this, because the `xpath` lookup below requires the original tokens:
|
|
50
|
+
template_id = header.pop(0).removeprefix(prefix).removeprefix(' ')
|
|
51
|
+
|
|
52
|
+
# Validate that `template_id` is a valid template
|
|
53
|
+
if template_id not in templates.keys():
|
|
54
|
+
yield dict(
|
|
55
|
+
line=comment.sourceline,
|
|
56
|
+
details=f'No such template: "{template_id}"',
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Build and validate the template for each test (with the corresponding namespace)
|
|
60
|
+
base_namespace = get_base_namespace(tool_xml_root)
|
|
61
|
+
for template_id, template in templates.items():
|
|
62
|
+
|
|
63
|
+
# Find tests that have templates defined for the check
|
|
64
|
+
if (inputs_xml_list := tool_xml_root.findall(f'./inputs')):
|
|
65
|
+
inputs_xml = inputs_xml_list[0]
|
|
66
|
+
for test_xml in list_tests(tool_xml_root):
|
|
67
|
+
full_prefix = f'{prefix} {template_id}' if template_id else prefix
|
|
68
|
+
if (
|
|
69
|
+
comment := _xpath_or_none(
|
|
70
|
+
test_xml,
|
|
71
|
+
f'./comment()[starts-with(normalize-space(.), "{full_prefix}")]',
|
|
72
|
+
)
|
|
73
|
+
) is not None:
|
|
74
|
+
comment_text = textwrap.dedent(comment.text).strip()
|
|
75
|
+
header = _list_nonempty_lines(comment_text)
|
|
76
|
+
header.pop(0)
|
|
77
|
+
|
|
78
|
+
# Build the template (with the corresponding namespace)
|
|
79
|
+
namespace = base_namespace | flat_dict_to_nested(get_test_inputs(inputs_xml, test_xml))
|
|
80
|
+
try:
|
|
81
|
+
result = str(Template(template.text, searchList=namespace))
|
|
82
|
+
except (ParseError, NameMapper.NotFound):
|
|
83
|
+
continue # Cheetah compile errors are handled by dedicated checks
|
|
84
|
+
|
|
85
|
+
# Validate that `actual` corresponds to `header`
|
|
86
|
+
actual = _list_nonempty_lines(textwrap.dedent(result))
|
|
87
|
+
if actual != header:
|
|
88
|
+
yield dict(
|
|
89
|
+
line=comment.sourceline,
|
|
90
|
+
details='\n'.join(
|
|
91
|
+
difflib.unified_diff(
|
|
92
|
+
header,
|
|
93
|
+
actual,
|
|
94
|
+
fromfile='expected',
|
|
95
|
+
tofile='actual',
|
|
96
|
+
lineterm='', # avoid extra newlines
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class Context(BaseException):
|
|
2
|
+
|
|
3
|
+
def __init__(self, code, description, filepath, line, details=None):
|
|
4
|
+
super().__init__(description)
|
|
5
|
+
self.code = code
|
|
6
|
+
self.description = description
|
|
7
|
+
self.filepath = filepath
|
|
8
|
+
self.line = line
|
|
9
|
+
self.details = details
|
|
10
|
+
|
|
11
|
+
def __str__(self):
|
|
12
|
+
where = str(self.filepath)
|
|
13
|
+
if self.line is not None:
|
|
14
|
+
where += f':{self.line}'
|
|
15
|
+
return f'{where} {self.description} ({self.code})'
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Issues with input and output datatypes
|
|
2
|
+
GIA101 = 'Use `format="tabular,tsv"` for table data input parameters or a superset thereof.'
|
|
3
|
+
GIA102 = 'Use `tiff` instead of `tif`'
|
|
4
|
+
|
|
5
|
+
# Issues with the `command` or `configfiles` sections
|
|
6
|
+
GIA201 = 'Zarr input images require special treatment within the `command` of the tool.'
|
|
7
|
+
GIA202 = 'Cheetah code is invalid.'
|
|
8
|
+
GIA203 = 'Cheetah code for `configfile` recognized as JSON but JSON code is invalid.'
|
|
9
|
+
GIA204 = 'Cheetah code yields not the expected result.'
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
|
|
3
|
+
from lxml import etree
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _create_simple_type_converter(target_type):
|
|
7
|
+
def _type(value: str):
|
|
8
|
+
if value == '':
|
|
9
|
+
return UnsetValue()
|
|
10
|
+
else:
|
|
11
|
+
return target_type(value)
|
|
12
|
+
return _type
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _create_boolean_converter(param):
|
|
16
|
+
def _type(checked):
|
|
17
|
+
return param.attrib.get(f'{checked.lower()}value', checked.lower())
|
|
18
|
+
return _type
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _create_type_converter(param):
|
|
22
|
+
param_type = param.attrib.get('type', '').lower()
|
|
23
|
+
assert param_type != '' # pre-condition
|
|
24
|
+
match param_type:
|
|
25
|
+
case 'integer':
|
|
26
|
+
return _create_simple_type_converter(int)
|
|
27
|
+
case 'float':
|
|
28
|
+
return _create_simple_type_converter(float)
|
|
29
|
+
case 'color':
|
|
30
|
+
return _create_simple_type_converter(str)
|
|
31
|
+
case 'boolean':
|
|
32
|
+
return _create_boolean_converter(param)
|
|
33
|
+
case 'data':
|
|
34
|
+
return InputDataset.converter(
|
|
35
|
+
param.attrib.get('multiple', '').lower() == 'true',
|
|
36
|
+
)
|
|
37
|
+
case _:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _list_parents(node, include_self=False, stop_at=('inputs', 'test', 'repeat')):
|
|
42
|
+
current = node
|
|
43
|
+
while current is node or current.tag not in stop_at:
|
|
44
|
+
if current is not node or include_self:
|
|
45
|
+
yield current
|
|
46
|
+
current = current.getparent()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_full_name(node):
|
|
50
|
+
tokens = list()
|
|
51
|
+
for p in _list_parents(node, include_self=True):
|
|
52
|
+
if (p_name := p.get('name')):
|
|
53
|
+
tokens.append(p_name)
|
|
54
|
+
elif len(tokens) == 0 and (p_argument := p.get('argument')):
|
|
55
|
+
tokens.append(p_argument.removeprefix('--').replace('-', '_'))
|
|
56
|
+
return '.'.join(tokens[::-1])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_node_by_name(full_name, root_xml, multiple: bool = False):
|
|
60
|
+
nodes = root_xml.findall(
|
|
61
|
+
'./' + '/'.join(f'*[@name="{name}"]' for name in full_name.split('.'))
|
|
62
|
+
)
|
|
63
|
+
if multiple:
|
|
64
|
+
return nodes
|
|
65
|
+
else:
|
|
66
|
+
return nodes[0] if nodes else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class InputDataset:
|
|
70
|
+
|
|
71
|
+
class Metadata:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
class ZarrMetadata(Metadata):
|
|
75
|
+
def store_root(self):
|
|
76
|
+
return 'store_root'
|
|
77
|
+
|
|
78
|
+
def __init__(self, filepath: str):
|
|
79
|
+
self._filepath = filepath
|
|
80
|
+
|
|
81
|
+
def __str__(self) -> str:
|
|
82
|
+
return self._filepath
|
|
83
|
+
|
|
84
|
+
def extension(self) -> str:
|
|
85
|
+
return '.'.join(self.name().split('.')[1:]).lower()
|
|
86
|
+
|
|
87
|
+
def ext(self) -> str:
|
|
88
|
+
return self.extension()
|
|
89
|
+
|
|
90
|
+
def file_ext(self) -> str:
|
|
91
|
+
return self.extension()
|
|
92
|
+
|
|
93
|
+
def name(self) -> str:
|
|
94
|
+
return pathlib.Path(self._filepath).name
|
|
95
|
+
|
|
96
|
+
def id(self) -> str:
|
|
97
|
+
return str(hash(self._filepath))
|
|
98
|
+
|
|
99
|
+
def element_identifier(self) -> str:
|
|
100
|
+
return self.id()
|
|
101
|
+
|
|
102
|
+
def extra_files_path(self) -> str:
|
|
103
|
+
return self._filepath
|
|
104
|
+
|
|
105
|
+
def metadata(self):
|
|
106
|
+
match self.extension():
|
|
107
|
+
case 'zarr' | 'ome_zarr':
|
|
108
|
+
return self.ZarrMetadata()
|
|
109
|
+
case _:
|
|
110
|
+
return self.Metadata()
|
|
111
|
+
|
|
112
|
+
def is_of_type(self, ext) -> bool:
|
|
113
|
+
"""
|
|
114
|
+
Mocks the behaviour of `galaxy.tools.wrappers.DatasetFilenameWrapper.is_of_type` naively.
|
|
115
|
+
"""
|
|
116
|
+
return ext.lower() in self.name().lower().split('.')[1:]
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def converter(multiple: bool = False):
|
|
120
|
+
def _converter(value: str):
|
|
121
|
+
if multiple:
|
|
122
|
+
return [
|
|
123
|
+
InputDataset(filepath) for filepath in value.split(',')
|
|
124
|
+
]
|
|
125
|
+
else:
|
|
126
|
+
return InputDataset(value)
|
|
127
|
+
return _converter
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class UnsetValue:
|
|
131
|
+
|
|
132
|
+
def __bool__(self):
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
def __str__(self):
|
|
136
|
+
return ''
|
|
137
|
+
|
|
138
|
+
def __eq__(self, other):
|
|
139
|
+
return isinstance(other, UnsetValue)
|
|
140
|
+
|
|
141
|
+
def __lt__(self, other):
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
def __gt__(self, other):
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_test_inputs(inputs_root, test_root):
|
|
149
|
+
inputs = dict() # mps the full name of an input parameter to its raw value (prior to type conversion)
|
|
150
|
+
input_types = dict() # maps the full name of an input parameter to its type converter
|
|
151
|
+
conditional_inputs = dict() # maps the full name of a conditional to its first input parameter
|
|
152
|
+
|
|
153
|
+
# Parse input parameters, default values, test values, and type converters
|
|
154
|
+
for node in inputs_root.xpath('.//param | .//repeat'):
|
|
155
|
+
full_node_name = get_full_name(node)
|
|
156
|
+
|
|
157
|
+
# Ignore the `node` if it is an ancestor of a `repeat` block (those are handled explicitly below),
|
|
158
|
+
# but ignore the tag of the root node
|
|
159
|
+
is_repeat_ancestor = False
|
|
160
|
+
for container in _list_parents(node, stop_at=tuple()):
|
|
161
|
+
if container is inputs_root:
|
|
162
|
+
break
|
|
163
|
+
if container.tag == 'repeat':
|
|
164
|
+
is_repeat_ancestor = True
|
|
165
|
+
break
|
|
166
|
+
if is_repeat_ancestor:
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# Skip if the parameter is inactive due to a parent conditional
|
|
170
|
+
is_active = True
|
|
171
|
+
for container in _list_parents(node):
|
|
172
|
+
if container.tag == 'when' and container.getparent().tag == 'conditional':
|
|
173
|
+
conditional_name = get_full_name(container.getparent())
|
|
174
|
+
if (
|
|
175
|
+
conditional_name not in conditional_inputs or
|
|
176
|
+
conditional_inputs[conditional_name] not in inputs or (
|
|
177
|
+
inputs[conditional_inputs[conditional_name]] != container.attrib.get('value')
|
|
178
|
+
)
|
|
179
|
+
):
|
|
180
|
+
is_active = False
|
|
181
|
+
break
|
|
182
|
+
if not is_active:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
# Handle `repeat` blocks recursively
|
|
186
|
+
if node.tag == 'repeat':
|
|
187
|
+
if len(inputs.setdefault(full_node_name, list())) == 0:
|
|
188
|
+
if (test_repeat_nodes := _get_node_by_name(full_node_name, test_root, multiple=True)):
|
|
189
|
+
for test_repeat_node in test_repeat_nodes:
|
|
190
|
+
inputs[full_node_name].append(get_test_inputs(node, test_repeat_node))
|
|
191
|
+
else:
|
|
192
|
+
for input_repeat_node in _get_node_by_name(full_node_name, inputs_root, multiple=True):
|
|
193
|
+
inputs[full_node_name].append(get_test_inputs(input_repeat_node, etree.Element('repeat')))
|
|
194
|
+
input_types[full_node_name] = list
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
# Skip invalid parameters (this is handled by `planemo lint`)
|
|
198
|
+
if (param_type := node.attrib.get('type', '').lower()) == '':
|
|
199
|
+
continue
|
|
200
|
+
else:
|
|
201
|
+
converter = _create_type_converter(node)
|
|
202
|
+
|
|
203
|
+
# Read the value from the `value` attribute
|
|
204
|
+
if param_type in ('text', 'integer', 'float', 'color', 'hidden', 'data'):
|
|
205
|
+
inputs[full_node_name] = node.attrib.get('value', '')
|
|
206
|
+
|
|
207
|
+
# Read the value from the `checked` attribute
|
|
208
|
+
if param_type in ('boolean',):
|
|
209
|
+
inputs[full_node_name] = node.attrib.get('checked', 'false')
|
|
210
|
+
|
|
211
|
+
# Read the value from children elements
|
|
212
|
+
if (default_options := node.xpath('./option[translate(@selected, "TRUE", "true")="true"]')):
|
|
213
|
+
inputs[full_node_name] = default_options[0].attrib.get('value') # read from selected option
|
|
214
|
+
elif (options := node.findall('./option')):
|
|
215
|
+
inputs[full_node_name] = options[0].attrib.get('value') # read from first option
|
|
216
|
+
|
|
217
|
+
# Register type converter
|
|
218
|
+
input_types[full_node_name] = converter or str
|
|
219
|
+
|
|
220
|
+
# Register the input for a conditional
|
|
221
|
+
if node.getparent().tag == 'conditional':
|
|
222
|
+
conditional_name = get_full_name(node.getparent())
|
|
223
|
+
conditional_inputs[conditional_name] = full_node_name
|
|
224
|
+
|
|
225
|
+
# Read test value
|
|
226
|
+
if (test_param := _get_node_by_name(full_node_name, test_root)) is not None:
|
|
227
|
+
inputs[full_node_name] = test_param.attrib.get('value', '')
|
|
228
|
+
|
|
229
|
+
# Return inputs and apply type converters
|
|
230
|
+
return {
|
|
231
|
+
key: (
|
|
232
|
+
None if value is None else input_types[key](value)
|
|
233
|
+
) for key, value in inputs.items()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def list_tests(tool_xml_root):
|
|
238
|
+
yield from tool_xml_root.findall('./tests/test')
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class Output:
|
|
242
|
+
|
|
243
|
+
def __init__(self, name: str):
|
|
244
|
+
self._name = name
|
|
245
|
+
|
|
246
|
+
def __str__(self) -> str:
|
|
247
|
+
return f'${self._name}'
|
|
248
|
+
|
|
249
|
+
def files_path(self) -> str:
|
|
250
|
+
return f'${self._name}.files_path'
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def get_base_namespace(tool_xml_root):
|
|
254
|
+
ns = {
|
|
255
|
+
'__tool_directory__': '$__tool_directory__',
|
|
256
|
+
'input': None, # shadow the built-in `input` because it causes hang-ups
|
|
257
|
+
}
|
|
258
|
+
for configfile in tool_xml_root.xpath('./configfiles/*[self::configfile or self::inputs]'):
|
|
259
|
+
if (name := configfile.attrib.get('name')):
|
|
260
|
+
ns[name] = f'${name}'
|
|
261
|
+
for output in tool_xml_root.findall('./outputs/data'):
|
|
262
|
+
if (name := output.attrib.get('name')):
|
|
263
|
+
ns[name] = Output(name)
|
|
264
|
+
return ns
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def flat_dict_to_nested(flat_dict):
|
|
268
|
+
root = dict()
|
|
269
|
+
for key, value in flat_dict.items():
|
|
270
|
+
path = key.split('.')
|
|
271
|
+
|
|
272
|
+
# Find/create the parent `dict` of where `value` must go
|
|
273
|
+
current = root
|
|
274
|
+
for token in path[:-1]:
|
|
275
|
+
current = current.setdefault(token, dict())
|
|
276
|
+
|
|
277
|
+
# Propagate to `value` if it is a list of dictionaries
|
|
278
|
+
if isinstance(value, list) and isinstance(value[0], dict):
|
|
279
|
+
value = [
|
|
280
|
+
flat_dict_to_nested(item) for item in value
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
# Put `value` into the hierarchy
|
|
284
|
+
current[path[-1]] = value
|
|
285
|
+
return root
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gialint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Linter for tool wrappers in Galaxy Image Analysis
|
|
5
|
+
Home-page: https://kostrykin.com
|
|
6
|
+
Author: Leonid Kostrykin
|
|
7
|
+
Author-email: leonid.kostrykin@bioquant.uni-heidelberg.de
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: galaxy-util>=25.0
|
|
12
|
+
Requires-Dist: lxml>=6.0.2
|
|
13
|
+
Requires-Dist: Cheetah3>=3.2.6.post1
|
|
14
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
15
|
+
Dynamic: author
|
|
16
|
+
Dynamic: author-email
|
|
17
|
+
Dynamic: home-page
|
|
18
|
+
Dynamic: license
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
Dynamic: requires-dist
|
|
21
|
+
Dynamic: requires-python
|
|
22
|
+
Dynamic: summary
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
setup.py
|
|
4
|
+
gialint/__init__.py
|
|
5
|
+
gialint/__main__.py
|
|
6
|
+
gialint/_context.py
|
|
7
|
+
gialint/codes.py
|
|
8
|
+
gialint/utils.py
|
|
9
|
+
gialint/version.py
|
|
10
|
+
gialint.egg-info/PKG-INFO
|
|
11
|
+
gialint.egg-info/SOURCES.txt
|
|
12
|
+
gialint.egg-info/dependency_links.txt
|
|
13
|
+
gialint.egg-info/requires.txt
|
|
14
|
+
gialint.egg-info/top_level.txt
|
|
15
|
+
gialint/_checks/__init__.py
|
|
16
|
+
gialint/_checks/gia101.py
|
|
17
|
+
gialint/_checks/gia102.py
|
|
18
|
+
gialint/_checks/gia201.py
|
|
19
|
+
gialint/_checks/gia202.py
|
|
20
|
+
gialint/_checks/gia203.py
|
|
21
|
+
gialint/_checks/gia204.py
|
|
22
|
+
tests/test_checks.py
|
|
23
|
+
tests/test_system.py
|
|
24
|
+
tests/test_utils.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gialint
|
gialint-0.1.0/setup.cfg
ADDED
gialint-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
from setuptools import setup
|
|
4
|
+
|
|
5
|
+
version = dict()
|
|
6
|
+
with open('gialint/version.py') as f:
|
|
7
|
+
exec(f.read(), version)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
setup(
|
|
11
|
+
name='gialint',
|
|
12
|
+
version=version['__version__'],
|
|
13
|
+
description='Linter for tool wrappers in Galaxy Image Analysis',
|
|
14
|
+
author='Leonid Kostrykin',
|
|
15
|
+
author_email='leonid.kostrykin@bioquant.uni-heidelberg.de',
|
|
16
|
+
url='https://kostrykin.com',
|
|
17
|
+
license='MIT',
|
|
18
|
+
packages=['gialint', 'gialint._checks'],
|
|
19
|
+
python_requires='>=3.10',
|
|
20
|
+
install_requires=[
|
|
21
|
+
'galaxy-util>=25.0',
|
|
22
|
+
'lxml>=6.0.2',
|
|
23
|
+
'Cheetah3>=3.2.6.post1',
|
|
24
|
+
'pyyaml>=6.0.3',
|
|
25
|
+
],
|
|
26
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
from lxml import etree
|
|
5
|
+
|
|
6
|
+
import gialint
|
|
7
|
+
|
|
8
|
+
tests_root_path = pathlib.Path(__file__).parent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _create_test(code, xml_tests_filepath, xml_test):
|
|
12
|
+
expected_lines_with_violations_str = xml_test.attrib.get('lines_with_volations', '')
|
|
13
|
+
expected_lines_with_violations = (
|
|
14
|
+
[int(line.strip()) for line in expected_lines_with_violations_str.split(',')]
|
|
15
|
+
if expected_lines_with_violations_str.strip() else list()
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def format_lines_list(lines_list):
|
|
19
|
+
return ', '.join(f'{line}' for line in lines_list) or 'none'
|
|
20
|
+
|
|
21
|
+
def test(testcase):
|
|
22
|
+
check_results = list(gialint.check(code, xml_test))
|
|
23
|
+
actual_lines_with_violations = [
|
|
24
|
+
info if isinstance(info, int) else info['line'] for info in check_results
|
|
25
|
+
]
|
|
26
|
+
if actual_lines_with_violations != expected_lines_with_violations:
|
|
27
|
+
testcase.fail(
|
|
28
|
+
f'{xml_tests_filepath}:{xml_test.sourceline}\n'
|
|
29
|
+
f' expected failures on lines: {format_lines_list(expected_lines_with_violations)}\n'
|
|
30
|
+
f' but actual failures were on: {format_lines_list(actual_lines_with_violations)}'
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return test
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _create_tests(code):
|
|
37
|
+
xml_tests_filepath = tests_root_path / 'checks' / f'{code}.xml'
|
|
38
|
+
xml_tests = etree.parse(xml_tests_filepath).getroot()
|
|
39
|
+
for xml_test in xml_tests:
|
|
40
|
+
if isinstance(xml_test, etree._Comment):
|
|
41
|
+
continue
|
|
42
|
+
assert xml_test.tag == 'test-tool', xml_test.tag
|
|
43
|
+
yield _create_test(code, xml_tests_filepath, xml_test)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CodesTestCase(unittest.TestCase):
|
|
47
|
+
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
for code in gialint.list_codes():
|
|
52
|
+
for test_idx, test in enumerate(_create_tests(code)):
|
|
53
|
+
setattr(CodesTestCase, f'test_{code}_{test_idx + 1}', test)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
repo_root_path = pathlib.Path(__file__).parent.parent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SystemTest(unittest.TestCase):
|
|
11
|
+
|
|
12
|
+
def setUp(self):
|
|
13
|
+
self.tempdir = tempfile.TemporaryDirectory()
|
|
14
|
+
subprocess.run(
|
|
15
|
+
';'.join(
|
|
16
|
+
[
|
|
17
|
+
'cd "{}"'.format(self.tempdir.name),
|
|
18
|
+
'{} -m venv ./venv'.format(sys.executable),
|
|
19
|
+
'./venv/bin/python -m pip install "{}" -qq'.format(repo_root_path),
|
|
20
|
+
]
|
|
21
|
+
),
|
|
22
|
+
shell=True,
|
|
23
|
+
check=True,
|
|
24
|
+
stdout=subprocess.PIPE,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def tearDown(self):
|
|
28
|
+
self.tempdir.cleanup()
|
|
29
|
+
|
|
30
|
+
def test(self):
|
|
31
|
+
result = subprocess.run(
|
|
32
|
+
';'.join(
|
|
33
|
+
[
|
|
34
|
+
'cd "{}"'.format(self.tempdir.name),
|
|
35
|
+
'git clone https://github.com/BMCV/galaxy-image-analysis.git',
|
|
36
|
+
'./venv/bin/python -m gialint --tool_path galaxy-image-analysis/tools',
|
|
37
|
+
]
|
|
38
|
+
),
|
|
39
|
+
shell=True,
|
|
40
|
+
check=False,
|
|
41
|
+
text=True,
|
|
42
|
+
stdout=subprocess.PIPE,
|
|
43
|
+
stderr=subprocess.PIPE,
|
|
44
|
+
)
|
|
45
|
+
if 'traceback' in result.stderr.lower():
|
|
46
|
+
print('\n', result.stderr, file=sys.stderr)
|
|
47
|
+
self.fail()
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
from lxml import etree
|
|
5
|
+
|
|
6
|
+
from gialint import utils
|
|
7
|
+
|
|
8
|
+
tools_root_path = pathlib.Path(__file__).parent / 'tools'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class flat_dict_to_nested(unittest.TestCase):
|
|
12
|
+
|
|
13
|
+
def test(self):
|
|
14
|
+
flat = {
|
|
15
|
+
'root.key1': '1',
|
|
16
|
+
'root.key2': '2',
|
|
17
|
+
'root.sub1.key1': '1.1',
|
|
18
|
+
'root.sub1.key2': '1.2',
|
|
19
|
+
'root.list': [
|
|
20
|
+
{
|
|
21
|
+
'key1': 'a',
|
|
22
|
+
'key2': 'b',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
'list_item_root.key1': 'c',
|
|
26
|
+
'list_item_root.key2': 'd',
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
}
|
|
30
|
+
self.assertEqual(
|
|
31
|
+
utils.flat_dict_to_nested(flat),
|
|
32
|
+
{
|
|
33
|
+
'root': {
|
|
34
|
+
'key1': '1',
|
|
35
|
+
'key2': '2',
|
|
36
|
+
'sub1': {
|
|
37
|
+
'key1': '1.1',
|
|
38
|
+
'key2': '1.2',
|
|
39
|
+
},
|
|
40
|
+
'list': [
|
|
41
|
+
{
|
|
42
|
+
'key1': 'a',
|
|
43
|
+
'key2': 'b',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
'list_item_root': {
|
|
47
|
+
'key1': 'c',
|
|
48
|
+
'key2': 'd',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class InputDataset(unittest.TestCase):
|
|
58
|
+
|
|
59
|
+
def setUp(self):
|
|
60
|
+
self.filepath = '/path/filename.ext1.ext2'
|
|
61
|
+
self.dataset = utils.InputDataset(self.filepath)
|
|
62
|
+
|
|
63
|
+
def test_ext(self):
|
|
64
|
+
self.assertEqual(self.dataset.ext(), 'ext1.ext2')
|
|
65
|
+
|
|
66
|
+
def test_extension(self):
|
|
67
|
+
self.assertEqual(self.dataset.extension(), 'ext1.ext2')
|
|
68
|
+
|
|
69
|
+
def test_file_ext(self):
|
|
70
|
+
self.assertEqual(self.dataset.file_ext(), 'ext1.ext2')
|
|
71
|
+
|
|
72
|
+
def test__str__(self):
|
|
73
|
+
self.assertEqual(str(self.dataset), self.filepath)
|
|
74
|
+
|
|
75
|
+
def test_name(self):
|
|
76
|
+
self.assertEqual(self.dataset.name(), 'filename.ext1.ext2')
|
|
77
|
+
|
|
78
|
+
def test_id(self):
|
|
79
|
+
self.assertEqual(self.dataset.id(), str(hash(self.filepath)))
|
|
80
|
+
|
|
81
|
+
def test_element_identifier(self):
|
|
82
|
+
self.assertEqual(self.dataset.element_identifier(), str(hash(self.filepath)))
|
|
83
|
+
|
|
84
|
+
def test_extra_files_path(self):
|
|
85
|
+
self.assertEqual(self.dataset.extra_files_path(), self.filepath)
|
|
86
|
+
|
|
87
|
+
def test_metadata(self):
|
|
88
|
+
self.assertFalse(hasattr(self.dataset.metadata(), 'store_root'))
|
|
89
|
+
self.assertEqual(
|
|
90
|
+
utils.InputDataset('filename.zarr').metadata().store_root(),
|
|
91
|
+
'store_root',
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def test_is_of_type(self):
|
|
95
|
+
self.assertTrue(self.dataset.is_of_type('ext1'))
|
|
96
|
+
self.assertTrue(self.dataset.is_of_type('ext2'))
|
|
97
|
+
self.assertFalse(self.dataset.is_of_type('filename'))
|
|
98
|
+
self.assertTrue(
|
|
99
|
+
utils.InputDataset('.txt').is_of_type('txt'),
|
|
100
|
+
)
|
|
101
|
+
self.assertFalse(
|
|
102
|
+
utils.InputDataset('txt').is_of_type('txt'),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ToolTest(unittest.TestCase):
|
|
107
|
+
|
|
108
|
+
tool: str
|
|
109
|
+
|
|
110
|
+
def setUp(self):
|
|
111
|
+
self.tool_xml_root = etree.parse(tools_root_path / self.tool).getroot()
|
|
112
|
+
if (inputs := self.tool_xml_root.findall('./inputs')):
|
|
113
|
+
self.inputs_xml = inputs[0]
|
|
114
|
+
else:
|
|
115
|
+
self.inputs_xml = None
|
|
116
|
+
self.test_xml_list = self.tool_xml_root.findall('./tests/test')
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class MinimalTest(ToolTest):
|
|
120
|
+
|
|
121
|
+
tool = 'utils_minimal.xml'
|
|
122
|
+
|
|
123
|
+
def test_list_tests(self):
|
|
124
|
+
self.assertEqual(len(list(utils.list_tests(self.tool_xml_root))), 1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class IllegalTest(ToolTest):
|
|
128
|
+
|
|
129
|
+
tool = 'utils_illegal.xml'
|
|
130
|
+
|
|
131
|
+
def test_list_tests(self):
|
|
132
|
+
self.assertEqual(len(list(utils.list_tests(self.tool_xml_root))), 1)
|
|
133
|
+
|
|
134
|
+
def test_get_test_inputs1(self):
|
|
135
|
+
test_inputs = (
|
|
136
|
+
utils.get_test_inputs(self.inputs_xml, self.test_xml_list[0])
|
|
137
|
+
)
|
|
138
|
+
self.assertEqual(test_inputs, dict())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class FullTest(ToolTest):
|
|
142
|
+
|
|
143
|
+
tool = 'utils_full.xml'
|
|
144
|
+
|
|
145
|
+
def test_list_tests(self):
|
|
146
|
+
self.assertEqual(len(list(utils.list_tests(self.tool_xml_root))), 3)
|
|
147
|
+
|
|
148
|
+
def test_get_test_inputs1(self):
|
|
149
|
+
test_inputs = (
|
|
150
|
+
utils.get_test_inputs(self.inputs_xml, self.test_xml_list[0])
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Validate input parameters without default values
|
|
154
|
+
self.assertEqual(test_inputs['section_1.no_name'], 'false')
|
|
155
|
+
self.assertEqual(test_inputs['section_1.text_without_default'], '')
|
|
156
|
+
self.assertEqual(test_inputs['text_without_default'], '')
|
|
157
|
+
self.assertEqual(test_inputs['hidden_without_default'], '')
|
|
158
|
+
for key in (
|
|
159
|
+
'integer_without_default',
|
|
160
|
+
'float_without_default',
|
|
161
|
+
'color_without_default',
|
|
162
|
+
):
|
|
163
|
+
with self.subTest(key):
|
|
164
|
+
self.assertIsInstance(test_inputs[key], utils.UnsetValue)
|
|
165
|
+
|
|
166
|
+
# Validate input parameters with default values
|
|
167
|
+
self.assertEqual(test_inputs['text_with_default'], 'default')
|
|
168
|
+
self.assertEqual(test_inputs['integer_with_default'], 1)
|
|
169
|
+
self.assertEqual(test_inputs['float_with_default'], 1.0)
|
|
170
|
+
self.assertEqual(test_inputs['boolean_1'], 'true')
|
|
171
|
+
self.assertEqual(test_inputs['boolean_2'], 'FALSE')
|
|
172
|
+
self.assertEqual(test_inputs['color_with_default'], '#ff0000')
|
|
173
|
+
self.assertEqual(test_inputs['hidden_with_default'], 'default')
|
|
174
|
+
self.assertEqual(test_inputs['select_1'], 'default')
|
|
175
|
+
self.assertEqual(test_inputs['select_2'], 'default')
|
|
176
|
+
self.assertEqual(test_inputs['section_1.text_with_default'], 'section_default')
|
|
177
|
+
self.assertNotIn('section_1', test_inputs)
|
|
178
|
+
|
|
179
|
+
# Validate conditionals
|
|
180
|
+
self.assertEqual(test_inputs['cond.text_1'], 'value_1')
|
|
181
|
+
self.assertEqual(test_inputs['cond.text_2'], 'value_2')
|
|
182
|
+
self.assertNotIn('cond', test_inputs)
|
|
183
|
+
self.assertNotIn('cond.text_0', test_inputs)
|
|
184
|
+
|
|
185
|
+
# Validate repeats
|
|
186
|
+
self.assertEqual(
|
|
187
|
+
test_inputs['repeat_1'],
|
|
188
|
+
[
|
|
189
|
+
dict(repeat_integer_with_default=1, repeat_integer_without_default=utils.UnsetValue()),
|
|
190
|
+
dict(repeat_integer_with_default=2, repeat_integer_without_default=utils.UnsetValue()),
|
|
191
|
+
],
|
|
192
|
+
)
|
|
193
|
+
self.assertEqual(
|
|
194
|
+
test_inputs['repeat_2'],
|
|
195
|
+
[
|
|
196
|
+
dict(repeat_integer_with_default=3, repeat_integer_without_default=utils.UnsetValue()),
|
|
197
|
+
],
|
|
198
|
+
)
|
|
199
|
+
self.assertEqual(
|
|
200
|
+
[
|
|
201
|
+
key for key in test_inputs
|
|
202
|
+
if key.startswith('repeat_')
|
|
203
|
+
],
|
|
204
|
+
[
|
|
205
|
+
'repeat_1',
|
|
206
|
+
'repeat_2',
|
|
207
|
+
],
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def test_get_test_inputs2(self):
|
|
211
|
+
test_inputs = (
|
|
212
|
+
utils.get_test_inputs(self.inputs_xml, self.test_xml_list[1])
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
self.assertEqual(test_inputs['text_with_default'], 'override')
|
|
216
|
+
self.assertEqual(test_inputs['text_without_default'], 'override')
|
|
217
|
+
self.assertEqual(test_inputs['integer_with_default'], 2)
|
|
218
|
+
self.assertEqual(test_inputs['integer_without_default'], 2)
|
|
219
|
+
self.assertEqual(test_inputs['float_with_default'], 2.0)
|
|
220
|
+
self.assertEqual(test_inputs['float_without_default'], 2.0)
|
|
221
|
+
self.assertEqual(test_inputs['boolean_1'], 'false')
|
|
222
|
+
self.assertEqual(test_inputs['boolean_2'], 'TRUE')
|
|
223
|
+
self.assertEqual(test_inputs['color_with_default'], '#00ff00')
|
|
224
|
+
self.assertEqual(test_inputs['color_without_default'], '#00ff00')
|
|
225
|
+
self.assertEqual(test_inputs['hidden_with_default'], 'override')
|
|
226
|
+
self.assertEqual(test_inputs['hidden_without_default'], 'override')
|
|
227
|
+
self.assertEqual(test_inputs['select_1'], 'other')
|
|
228
|
+
self.assertEqual(test_inputs['select_2'], 'other')
|
|
229
|
+
self.assertEqual(test_inputs['section_1.text_with_default'], 'section_override')
|
|
230
|
+
self.assertEqual(test_inputs['section_1.text_without_default'], 'section_override')
|
|
231
|
+
self.assertEqual(test_inputs['section_1.no_name'], 'true')
|
|
232
|
+
self.assertNotIn('section_1', test_inputs)
|
|
233
|
+
|
|
234
|
+
# Validate conditionals
|
|
235
|
+
self.assertEqual(test_inputs['cond.text_0'], 'override')
|
|
236
|
+
self.assertEqual(test_inputs['cond.text_1'], 'value_3')
|
|
237
|
+
self.assertNotIn('cond', test_inputs)
|
|
238
|
+
self.assertNotIn('cond.text_2', test_inputs)
|
|
239
|
+
|
|
240
|
+
# Validate repeats
|
|
241
|
+
self.assertEqual(
|
|
242
|
+
test_inputs['repeat_1'],
|
|
243
|
+
[
|
|
244
|
+
dict(repeat_integer_with_default=10, repeat_integer_without_default=11),
|
|
245
|
+
],
|
|
246
|
+
)
|
|
247
|
+
self.assertEqual(
|
|
248
|
+
test_inputs['repeat_2'],
|
|
249
|
+
[
|
|
250
|
+
dict(repeat_integer_with_default=20, repeat_integer_without_default=21),
|
|
251
|
+
dict(repeat_integer_with_default=30, repeat_integer_without_default=31),
|
|
252
|
+
],
|
|
253
|
+
)
|
|
254
|
+
self.assertEqual(
|
|
255
|
+
[
|
|
256
|
+
key for key in test_inputs
|
|
257
|
+
if key.startswith('repeat_')
|
|
258
|
+
],
|
|
259
|
+
[
|
|
260
|
+
'repeat_1',
|
|
261
|
+
'repeat_2',
|
|
262
|
+
],
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def test_get_test_inputs3(self):
|
|
266
|
+
test_inputs = (
|
|
267
|
+
utils.get_test_inputs(self.inputs_xml, self.test_xml_list[2])
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
self.assertEqual(test_inputs['boolean_1'], 'false')
|
|
271
|
+
self.assertEqual(test_inputs['boolean_2'], 'TRUE')
|