diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cb087de..95e0d70 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,6 @@ name: -on: [push] +on: [push, pull_request] jobs: test_conda: diff --git a/acclimatise/__init__.py b/acclimatise/__init__.py index b7886a6..6943ef2 100644 --- a/acclimatise/__init__.py +++ b/acclimatise/__init__.py @@ -9,6 +9,7 @@ from acclimatise.converter import WrapperGenerator from acclimatise.converter.cwl import CwlGenerator +from acclimatise.converter.galaxy import GalaxyGenerator from acclimatise.converter.wdl import WdlGenerator from acclimatise.converter.yml import YmlGenerator from acclimatise.execution import execute_cmd diff --git a/acclimatise/cli.py b/acclimatise/cli.py index 16c19b3..4881bc7 100644 --- a/acclimatise/cli.py +++ b/acclimatise/cli.py @@ -63,9 +63,9 @@ def main(): "--format", "-f", "formats", - type=click.Choice(["wdl", "cwl", "yml"]), + type=click.Choice(["wdl", "cwl", "yml", "galaxy"]), multiple=True, - default=("yml", "wdl", "cwl"), + default=("yml", "wdl", "cwl", "galaxy"), help="The language in which to output the CLI wrapper", ) @click.option( @@ -123,7 +123,7 @@ def explore( @click.option( "--format", "-f", - type=click.Choice(["wdl", "cwl", "yml"]), + type=click.Choice(["wdl", "cwl", "yml", "galaxy"]), default="cwl", help="The language in which to output the CLI wrapper", ) diff --git a/acclimatise/converter/__init__.py b/acclimatise/converter/__init__.py index 9666a13..6a75608 100644 --- a/acclimatise/converter/__init__.py +++ b/acclimatise/converter/__init__.py @@ -47,7 +47,7 @@ def choose_converter(cls, typ) -> Type["WrapperGenerator"]: if subclass.format() == typ: return subclass - raise Exception("Unknown format type") + raise Exception("Unknown format type %s" % typ) @classmethod @abstractmethod diff --git a/acclimatise/converter/galaxy.py b/acclimatise/converter/galaxy.py index ae09127..5fa4bf5 100644 --- a/acclimatise/converter/galaxy.py +++ b/acclimatise/converter/galaxy.py @@ -7,10 +7,14 @@ from dataclasses import dataclass +import galaxyxml.tool as gxt +import galaxyxml.tool.parameters as gxtp from acclimatise import cli_types from acclimatise.converter import NamedArgument, WrapperGenerator from acclimatise.model import CliArgument, Command, Flag, Positional from acclimatise.yaml import yaml +from galaxy.tool_util.lint import LEVEL_ALL, LEVEL_ERROR, LEVEL_WARN, lint_tool_source +from galaxy.tool_util.parser import get_tool_source @dataclass @@ -25,14 +29,110 @@ def format(cls) -> str: def suffix(self) -> str: return ".xml" + @staticmethod + def to_gxy_class(typ: cli_types.CliType): + if isinstance(typ, cli_types.CliFile): + return gxtp.DataParam + elif isinstance(typ, cli_types.CliDir): + return gxtp.DataParam # can make composite datatype + elif isinstance(typ, cli_types.CliString): + return gxtp.TextParam + elif isinstance(typ, cli_types.CliFloat): + return gxtp.FloatParam + elif isinstance(typ, cli_types.CliInteger): + return gxtp.IntegerParam + elif isinstance(typ, cli_types.CliBoolean): + return gxtp.BooleanParam + # elif isinstance(typ, cli_types.CliEnum): + # return gxtp.BooleanParam + # elif isinstance(typ, cli_types.CliList): + # return CwlGenerator.to_cwl_type(typ.value) + "[]" + # elif isinstance(typ, cli_types.CliTuple): + # return [CwlGenerator.to_cwl_type(subtype) for subtype in set(typ.values)] + else: + raise Exception(f"Invalid type {typ}!") + def save_to_string(self, cmd: Command) -> str: - # Todo - pass + # Some current limits?: + # No package name information + # No version information + # No outputs + + inputs: List[CliArgument] = [*cmd.named] + ( + [] if self.ignore_positionals else [*cmd.positional] + ) + names = self.choose_variable_names(inputs) + + tool_name = cmd.as_filename + tool_id = cmd.as_filename + tool_version = "0.0.1" + tool_description = "" + tool_executable = " ".join(cmd.command) + version_command = "%s %s" % (tool_executable, cmd.version_flag.full_name()) + tool = gxt.Tool( + tool_name, + tool_id, + tool_version, + tool_description, + tool_executable, + hidden=False, + tool_type=None, + URL_method=None, + workflow_compatible=True, + interpreter=None, + version_command=version_command, + ) + + tool.inputs = gxtp.Inputs() + tool.outputs = gxtp.Outputs() + tool.help = self._format_help(cmd.help_text) - def save_to_file(self, cmd: Command, path: Path) -> None: - # Todo - pass + tool.tests = gxtp.Tests() # ToDo: add tests + tool.citations = gxtp.Citations() # ToDo: add citations + + # Add requirements + requirements = gxtp.Requirements() + requirements.append(gxtp.Requirement("package", tool_executable, version=None)) + tool.requirements = requirements + + for arg in names: + assert arg.name != "", arg + param_cls = self.to_gxy_class(arg.arg.get_type()) + # not yet handled: + # default values? + # ints & floats: min, max + param = param_cls( + arg.name, + label=arg.arg.description, + positional=isinstance(arg.arg, Positional), + help=arg.arg.description, + value=None, + num_dashes=len(arg.arg.longest_synonym) + - len(arg.arg.longest_synonym.lstrip("-")), + optional=arg.arg.optional, + ) + # output or input? + tool.inputs.append(param) + return tool.export() @classmethod def validate(cls, wrapper: str, cmd: Command = None, explore=True): - pass + # ToDo: Tests? What level to validate? + # Is wrapper assumed to be generated here, or should we also compare to result of output of save_to_string (as if wrapper was being generated externally) + # Raise value error if validation fails + with tempfile.NamedTemporaryFile(mode="w+", suffix=".xml") as fh: + fh.write(wrapper) + fh.flush() + tool_source = get_tool_source(config_file=fh.name) + if not lint_tool_source( + tool_source, level=LEVEL_ALL, fail_level=LEVEL_WARN + ): + raise ValueError("Linting Failed") + return True + + def _format_help(self, help_text): + # Just cheat and make it a huge block quote + rval = "::\n" + for line in help_text.split("\n"): + rval = "%s\n %s" % (rval, line.rstrip()) + return "%s\n\n" % (rval) diff --git a/acclimatise/model.py b/acclimatise/model.py index 53eb03a..cb753e0 100644 --- a/acclimatise/model.py +++ b/acclimatise/model.py @@ -78,6 +78,13 @@ def as_filename(self) -> str: """ return "_".join(self.command).replace("-", "_") + @property + def empty(self) -> bool: + """ + True if we think this command failed in parsing, ie it has no arguments + """ + return (len(self.positional) + len(self.named) + len(self.subcommands)) == 0 + @property def depth(self) -> int: """ diff --git a/acclimatise/usage_parser/__init__.py b/acclimatise/usage_parser/__init__.py index 37a754c..3b63391 100644 --- a/acclimatise/usage_parser/__init__.py +++ b/acclimatise/usage_parser/__init__.py @@ -16,7 +16,7 @@ def normalise_cline(tokens): return [Path(el.lower()).stem for el in tokens] -def parse_usage(cmd, text, debug=False): +def parse_usage(cmd, text, debug=False) -> Command: toks = usage.setDebug(debug).searchString(text) if not toks: # If we had no results, return an empty command diff --git a/acclimatise/usage_parser/elements.py b/acclimatise/usage_parser/elements.py index a91f0d6..8f5b048 100644 --- a/acclimatise/usage_parser/elements.py +++ b/acclimatise/usage_parser/elements.py @@ -181,8 +181,10 @@ def visit_usage(s, loc, toks): return toks[0][0] -usage = Regex("usage:", flags=re.IGNORECASE).suppress() + OneOrMore( - usage_element, stopOn=LineEnd() +usage = ( + LineStart() + + Regex("usage:", flags=re.IGNORECASE).suppress() + + OneOrMore(usage_element, stopOn=LineEnd()) ) # .setParseAction(visit_usage).setDebug() diff --git a/setup.py b/setup.py index c709212..f43bb13 100755 --- a/setup.py +++ b/setup.py @@ -7,7 +7,6 @@ "pyparsing", "jinja2", "spacy", - "cwlgen", "miniwdl", "wordsegment", "inflection", @@ -21,6 +20,8 @@ "word2number", "psutil", "dataclasses", + "galaxyxml", + "galaxy-tool-util", ], python_requires=">=3.6", entry_points={"console_scripts": ["acclimatise = acclimatise.cli:main"]}, diff --git a/test/usage/test_usage.py b/test/usage/test_usage.py index 380b396..cb519f7 100644 --- a/test/usage/test_usage.py +++ b/test/usage/test_usage.py @@ -174,3 +174,11 @@ def test_samtools_dict(): """ command = parse_usage(["samtools", "dict"], text, debug=True) assert len(command.positional) == 1 + + +def test_mid_line_usage(): + text = """ + Can't open --usage: No such file or directory at /usr/bin/samtools.pl line 50. + """ + command = parse_usage(["samtools.pl", "showALEN"], text, debug=True) + assert command.empty