Skip to content

Commit

Permalink
Merge pull request #1519 from nf-core/feat-mulled
Browse files Browse the repository at this point in the history
feat: add a modules mulled command
  • Loading branch information
Midnighter authored Apr 21, 2022
2 parents faf4716 + 893fc9a commit 6052563
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
### Modules

- Escaped test run output before logging it, to avoid a rich ` MarkupError`
- Add a new command `nf-core modules mulled` which can generate the name for a multi-tool container image.

## [v2.3.2 - Mercury Vulture Fixed Formatting](https://github.com/nf-core/tools/releases/tag/2.3.2) - [2022-03-24]

Expand Down
39 changes: 37 additions & 2 deletions nf_core/__main__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#!/usr/bin/env python
""" nf-core: Helper tools for use with nf-core Nextflow pipelines. """

from rich import print
import logging
import os
Expand Down Expand Up @@ -49,7 +48,7 @@
},
{
"name": "Developing new modules",
"commands": ["create", "create-test-yml", "lint", "bump-versions"],
"commands": ["create", "create-test-yml", "lint", "bump-versions", "mulled"],
},
],
}
Expand Down Expand Up @@ -674,6 +673,42 @@ def bump_versions(ctx, tool, dir, all, show_all):
sys.exit(1)


# nf-core modules mulled
@modules.command()
@click.argument("specifications", required=True, nargs=-1, metavar="<tool==version> <...>")
@click.option(
"--build-number",
type=int,
default=0,
show_default=True,
metavar="<number>",
help="The build number for this image. This is an incremental value that starts at zero.",
)
def mulled(specifications, build_number):
"""
Generate the name of a BioContainers mulled image version 2.
When you know the specific dependencies and their versions of a multi-tool container image and you need the name of
that image, this command can generate it for you.
"""
from nf_core.modules.mulled import MulledImageNameGenerator

image_name = MulledImageNameGenerator.generate_image_name(
MulledImageNameGenerator.parse_targets(specifications), build_number=build_number
)
print(image_name)
if not MulledImageNameGenerator.image_exists(image_name):
log.error(
"The generated multi-tool container image name does not seem to exist yet. Please double check that your "
"provided combination of tools and versions exists in the file:\n"
"https://github.com/BioContainers/multi-package-containers/blob/master/combinations/hash.tsv\n"
"If it does not, please add your desired combination as detailed at:\n"
"https://github.com/BioContainers/multi-package-containers\n"
)
sys.exit(1)


# nf-core schema subcommands
@nf_core_cli.group()
def schema():
Expand Down
1 change: 1 addition & 0 deletions nf_core/modules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
from .update import ModuleUpdate
from .remove import ModuleRemove
from .info import ModuleInfo
from .mulled import MulledImageNameGenerator
67 changes: 67 additions & 0 deletions nf_core/modules/mulled.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Generate the name of a BioContainers mulled image version 2."""


import logging
import re
from packaging.version import Version, InvalidVersion
from typing import Iterable, Tuple, List

import requests
from galaxy.tool_util.deps.mulled.util import build_target, v2_image_name


log = logging.getLogger(__name__)


class MulledImageNameGenerator:
"""
Define a service class for generating BioContainers version 2 mulled image names.
Adapted from https://gist.github.com/natefoo/19cefeedd1942c30f9d88027a61b3f83.
"""

_split_pattern = re.compile(r"==?")

@classmethod
def parse_targets(cls, specifications: Iterable[str]) -> List[Tuple[str, str]]:
"""
Parse tool, version pairs from specification strings.
Args:
specifications: An iterable of strings that contain tools and their versions.
"""
result = []
for spec in specifications:
try:
tool, version = cls._split_pattern.split(spec, maxsplit=1)
except ValueError:
raise ValueError(
f"The specification {spec} does not have the expected format <tool==version> or <tool=version>."
) from None
try:
Version(version)
except InvalidVersion:
raise ValueError(f"{version} in {spec} is not a PEP440 compliant version specification.") from None
result.append((tool.strip(), version.strip()))
return result

@classmethod
def generate_image_name(cls, targets: Iterable[Tuple[str, str]], build_number: int = 0) -> str:
"""
Generate the name of a BioContainers mulled image version 2.
Args:
targets: One or more tool, version pairs of the multi-tool container image.
build_number: The build number for this image. This is an incremental value that starts at zero.
"""
return v2_image_name([build_target(name, version) for name, version in targets], image_build=str(build_number))

@classmethod
def image_exists(cls, image_name: str) -> bool:
"""Check whether a given BioContainers image name exists via a call to the quay.io API."""
response = requests.get(f"https://quay.io/biocontainers/{image_name}/", allow_redirects=True)
log.debug(response.text)
return response.status_code == 200
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
click
galaxy-tool-util
GitPython
jinja2
jsonschema>=3.0
Expand Down
65 changes: 65 additions & 0 deletions tests/test_mullled.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Test the mulled BioContainers image name generation."""

import pytest

from nf_core.modules import MulledImageNameGenerator


@pytest.mark.parametrize(
"specs, expected",
[
(["foo==0.1.2", "bar==1.1"], [("foo", "0.1.2"), ("bar", "1.1")]),
(["foo=0.1.2", "bar=1.1"], [("foo", "0.1.2"), ("bar", "1.1")]),
],
)
def test_target_parsing(specs, expected):
"""Test that valid specifications are correctly parsed into tool, version pairs."""
assert MulledImageNameGenerator.parse_targets(specs) == expected


@pytest.mark.parametrize(
"specs",
[
["foo<0.1.2", "bar==1.1"],
["foo=0.1.2", "bar>1.1"],
],
)
def test_wrong_specification(specs):
"""Test that unexpected version constraints fail."""
with pytest.raises(ValueError, match="expected format"):
MulledImageNameGenerator.parse_targets(specs)


@pytest.mark.parametrize(
"specs",
[
["foo==0a.1.2", "bar==1.1"],
["foo==0.1.2", "bar==1.b1b"],
],
)
def test_noncompliant_version(specs):
"""Test that version string that do not comply with PEP440 fail."""
with pytest.raises(ValueError, match="PEP440"):
MulledImageNameGenerator.parse_targets(specs)


@pytest.mark.parametrize(
"specs, expected",
[
(
[("chromap", "0.2.1"), ("samtools", "1.15")],
"mulled-v2-1f09f39f20b1c4ee36581dc81cc323c70e661633:bd74d08a359024829a7aec1638a28607bbcd8a58-0",
),
(
[("pysam", "0.16.0.1"), ("biopython", "1.78")],
"mulled-v2-3a59640f3fe1ed11819984087d31d68600200c3f:185a25ca79923df85b58f42deb48f5ac4481e91f-0",
),
(
[("samclip", "0.4.0"), ("samtools", "1.15")],
"mulled-v2-d057255d4027721f3ab57f6a599a2ae81cb3cbe3:13051b049b6ae536d76031ba94a0b8e78e364815-0",
),
],
)
def test_generate_image_name(specs, expected):
"""Test that a known image name is generated from given targets."""
assert MulledImageNameGenerator.generate_image_name(specs) == expected

0 comments on commit 6052563

Please sign in to comment.