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 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
@@ -0,0 +1,13 @@
1
+ # galaxy-image-analysis-lint
2
+
3
+ [![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/kostrykin/fff2bce97225bf905bae26d517a6c41c/raw/gialint.json)](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,5 @@
1
+ VERSION_MAJOR = 0
2
+ VERSION_MINOR = 1
3
+ VERSION_PATCH = 0
4
+
5
+ __version__ = '%d.%d.%d' % (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH)
@@ -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,4 @@
1
+ galaxy-util>=25.0
2
+ lxml>=6.0.2
3
+ Cheetah3>=3.2.6.post1
4
+ pyyaml>=6.0.3
@@ -0,0 +1 @@
1
+ gialint
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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')