Template v5.5.0 updates
This commit is contained in:
parent
9701041ce5
commit
b4bba1432b
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,3 +5,4 @@ template-workflow
|
|||||||
.*.swo
|
.*.swo
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
|
.DS_store
|
||||||
|
|||||||
@ -9,14 +9,6 @@ repos:
|
|||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- epi2melabs==0.0.57
|
- epi2melabs==0.0.57
|
||||||
- id: build_models
|
|
||||||
name: build_models
|
|
||||||
entry: datamodel-codegen --strict-nullable --base-class workflow_glue.results_schema_helpers.BaseModel --use-subclass-enum --use-schema-description --disable-timestamp --input results_schema.yml --input-file-type openapi --output bin/workflow_glue/results_schema.py
|
|
||||||
language: python
|
|
||||||
files: 'results_schema.yml'
|
|
||||||
pass_filenames: false
|
|
||||||
additional_dependencies:
|
|
||||||
- datamodel-code-generator
|
|
||||||
- repo: https://github.com/pycqa/flake8
|
- repo: https://github.com/pycqa/flake8
|
||||||
rev: 5.0.4
|
rev: 5.0.4
|
||||||
hooks:
|
hooks:
|
||||||
@ -37,5 +29,5 @@ repos:
|
|||||||
"--import-order-style=google",
|
"--import-order-style=google",
|
||||||
"--statistics",
|
"--statistics",
|
||||||
"--max-line-length=88",
|
"--max-line-length=88",
|
||||||
"--extend-exclude=bin/workflow_glue/results_schema.py",
|
"--per-file-ignores=bin/workflow_glue/models/*:NT001",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- `split_bam` and `build_minimap_index_transcriptome` process memory allocation increased.
|
- `split_bam` and `build_minimap_index_transcriptome` process memory allocation increased.
|
||||||
- Updated recommended memory requirement.
|
- Updated recommended memory requirement.
|
||||||
- Updated project description.
|
- Updated project description.
|
||||||
|
- Reconciled workflow with wf-template v5.5.0.
|
||||||
### Fixed
|
### Fixed
|
||||||
- `all_gene_counts.tsv` contained the DE counts results.
|
- `all_gene_counts.tsv` contained the DE counts results.
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import glob
|
import glob
|
||||||
import importlib
|
import importlib
|
||||||
|
import itertools
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@ -11,13 +12,22 @@ from .util import _log_level, get_main_logger # noqa: ABS101
|
|||||||
__version__ = "0.0.1"
|
__version__ = "0.0.1"
|
||||||
_package_name = "workflow_glue"
|
_package_name = "workflow_glue"
|
||||||
|
|
||||||
|
HELPERS = "wfg_helpers"
|
||||||
|
|
||||||
|
|
||||||
def get_components(allowed_components=None):
|
def get_components(allowed_components=None):
|
||||||
"""Find a list of workflow command scripts."""
|
"""Find a list of workflow command scripts."""
|
||||||
logger = get_main_logger(_package_name)
|
logger = get_main_logger(_package_name)
|
||||||
path = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
|
# gather all python files in the current directory and the wfg_helpers
|
||||||
|
home_path = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
standard_lib = os.path.join(home_path, HELPERS)
|
||||||
|
globs = itertools.chain.from_iterable((
|
||||||
|
glob.glob(os.path.join(path, "*.py"))
|
||||||
|
for path in (home_path, standard_lib)))
|
||||||
|
|
||||||
components = dict()
|
components = dict()
|
||||||
for fname in glob.glob(os.path.join(path, "*.py")):
|
for fname in globs:
|
||||||
name = os.path.splitext(os.path.basename(fname))[0]
|
name = os.path.splitext(os.path.basename(fname))[0]
|
||||||
if name in ("__init__", "util"):
|
if name in ("__init__", "util"):
|
||||||
continue
|
continue
|
||||||
@ -26,6 +36,9 @@ def get_components(allowed_components=None):
|
|||||||
|
|
||||||
# leniently attempt to import module
|
# leniently attempt to import module
|
||||||
try:
|
try:
|
||||||
|
if HELPERS in fname:
|
||||||
|
mod = importlib.import_module(f"{_package_name}.{HELPERS}.{name}")
|
||||||
|
else:
|
||||||
mod = importlib.import_module(f"{_package_name}.{name}")
|
mod = importlib.import_module(f"{_package_name}.{name}")
|
||||||
except ModuleNotFoundError as e:
|
except ModuleNotFoundError as e:
|
||||||
# if imports cannot be satisifed, refuse to add the component
|
# if imports cannot be satisifed, refuse to add the component
|
||||||
|
|||||||
1
bin/workflow_glue/models/__init__.py
Normal file
1
bin/workflow_glue/models/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""A collection of scripts for results models."""
|
||||||
206
bin/workflow_glue/models/common.py
Normal file
206
bin/workflow_glue/models/common.py
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
"""Common model classes used across all workflows."""
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from decimal import Decimal
|
||||||
|
from enum import Enum
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
class SampleType(str, Enum):
|
||||||
|
"""The type of the sample."""
|
||||||
|
|
||||||
|
no_template_control = "no_template_control"
|
||||||
|
positive_control = "positive_control"
|
||||||
|
negative_control = "negative_control"
|
||||||
|
test_sample = "test_sample"
|
||||||
|
|
||||||
|
def friendly_name(self):
|
||||||
|
"""Convert sample type to string."""
|
||||||
|
return self.name.replace("_", " ").capitalize()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SampleIdentifier:
|
||||||
|
"""Additional identifiers for a sample."""
|
||||||
|
|
||||||
|
name: str = field(
|
||||||
|
metadata={
|
||||||
|
"title": "Identifier name",
|
||||||
|
"Description": "The name of the sample identifier"})
|
||||||
|
value: str = field(
|
||||||
|
metadata={
|
||||||
|
"title": "Identifier value",
|
||||||
|
"Description": "The value of the sample identifier"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CheckResult:
|
||||||
|
"""
|
||||||
|
A result of some check the workflow has performed.
|
||||||
|
|
||||||
|
This can be at sample or workflow level.
|
||||||
|
"""
|
||||||
|
|
||||||
|
check_category: str = field(
|
||||||
|
metadata={
|
||||||
|
"title": "Check category",
|
||||||
|
"description": "The category of the check"})
|
||||||
|
check_name: str = field(
|
||||||
|
metadata={
|
||||||
|
"title": "Check name",
|
||||||
|
"description": "The name of the check"})
|
||||||
|
check_pass: bool = field(
|
||||||
|
metadata={
|
||||||
|
"title": "Check pass",
|
||||||
|
"description": "If true the check has passed"})
|
||||||
|
check_threshold: str | None = field(
|
||||||
|
default=None, metadata={
|
||||||
|
"title": "Check threshold",
|
||||||
|
"description": "The threshold for the check, useful for reporting later"})
|
||||||
|
|
||||||
|
categories = {}
|
||||||
|
|
||||||
|
def friendly_check_category(self):
|
||||||
|
"""Convert category to string."""
|
||||||
|
if self.check_category not in self.categories:
|
||||||
|
raise ValueError(f"{self.check_category} has no friendly name")
|
||||||
|
return self.categories[self.check_category]
|
||||||
|
|
||||||
|
def friendly_check_name(self):
|
||||||
|
"""Convert check name to string."""
|
||||||
|
return self.check_name.replace("_", " ").capitalize()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResultsContents:
|
||||||
|
"""Placeholder class for results contents."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Sample:
|
||||||
|
"""A sample sheet entry and its corresponding checks and related results."""
|
||||||
|
|
||||||
|
alias: str = field(
|
||||||
|
metadata={
|
||||||
|
"title": "Sample alias",
|
||||||
|
"description": "The alias for the sample given by the user"})
|
||||||
|
barcode: str = field(
|
||||||
|
metadata={
|
||||||
|
"title": "Sample barcode",
|
||||||
|
"description": "The physical barcode assigned to the sample"})
|
||||||
|
sample_type: SampleType = field(
|
||||||
|
metadata={
|
||||||
|
"title": "Sample type",
|
||||||
|
"description": "The type of the sample"})
|
||||||
|
sample_pass: bool = field(
|
||||||
|
metadata={
|
||||||
|
"title": "Sample pass",
|
||||||
|
"description": "If true the sample has passed workflow checks"})
|
||||||
|
additional_identifiers: List[SampleIdentifier] = field(
|
||||||
|
default_factory=list, metadata={
|
||||||
|
"title": "Additional sample identifiers",
|
||||||
|
"description": "Addition identifiers for the sample"})
|
||||||
|
sample_checks: list[CheckResult] = field(
|
||||||
|
default_factory=list, metadata={
|
||||||
|
"title": "Sample checks",
|
||||||
|
"description": "An array of checks performed on the sample"})
|
||||||
|
results: ResultsContents | None = field(
|
||||||
|
default=None, metadata={
|
||||||
|
"title": "Sample results",
|
||||||
|
"description": "Further specific workflow results for this sample"})
|
||||||
|
config: Dict[str, Any] | None = field(
|
||||||
|
default=None, metadata={
|
||||||
|
"title": "Sample configuration",
|
||||||
|
"description": """Sample specific config parameters
|
||||||
|
used for running analysis"""})
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Determine overall status for a sample given the individual check results."""
|
||||||
|
self.sample_pass = all(
|
||||||
|
check.check_pass for check in self.sample_checks)
|
||||||
|
|
||||||
|
def get_sample_identifier(self, sample_identifier):
|
||||||
|
"""Get a sample identifier given the identifier name."""
|
||||||
|
for indentifier in self.additional_identifiers:
|
||||||
|
if indentifier.name == sample_identifier:
|
||||||
|
return indentifier.value
|
||||||
|
raise KeyError("Sample identifier not found")
|
||||||
|
|
||||||
|
def set_sample_identifier(self, name, value):
|
||||||
|
"""Set a sample identifier."""
|
||||||
|
sample_identifier = SampleIdentifier(
|
||||||
|
name=name,
|
||||||
|
value=value)
|
||||||
|
self.additional_identifiers.append(sample_identifier)
|
||||||
|
return self.additional_identifiers
|
||||||
|
|
||||||
|
def to_json(self, filename):
|
||||||
|
"""Save class as JSON."""
|
||||||
|
with open(filename, 'w') as f:
|
||||||
|
json.dump(asdict(self), f, default=str, indent=2, cls=DecimalEncoder)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunStats:
|
||||||
|
"""Basic run statistics for the entire run."""
|
||||||
|
|
||||||
|
total_reads: int | None = field(
|
||||||
|
default=None, metadata={
|
||||||
|
"title": "Total reads",
|
||||||
|
"description": "Total number of reads on run"})
|
||||||
|
total_ambiguous_reads: int | None = field(
|
||||||
|
default=None, metadata={
|
||||||
|
"title": "Total ambiguous reads",
|
||||||
|
"description": "Number of reads of unknown provenance"})
|
||||||
|
total_unaligned_reads: int | None = field(
|
||||||
|
default=None, metadata={
|
||||||
|
"title": "Total unaligned reads",
|
||||||
|
"description": "Number of unaligned reads"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WorkflowResult():
|
||||||
|
"""
|
||||||
|
Definition for results that will be returned by this workflow.
|
||||||
|
|
||||||
|
This structure will be passed through by Gizmo speaking clients
|
||||||
|
as WorkflowInstance.results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
samples: list[Sample] = field(
|
||||||
|
metadata={
|
||||||
|
"title": "Samples",
|
||||||
|
"description": "Samples in this workflow instance"})
|
||||||
|
workflow_pass: bool | None = field(
|
||||||
|
default=None, metadata={
|
||||||
|
"title": "Workflow pass",
|
||||||
|
"description": "True if this workflow instance passes all checks"})
|
||||||
|
workflow_checks: list[CheckResult] = field(
|
||||||
|
default_factory=list, metadata={
|
||||||
|
"title": "Workflow checks",
|
||||||
|
"description": "An array of checks performed on the workflow instance"})
|
||||||
|
run_stats: RunStats | None = field(
|
||||||
|
default=None, metadata={
|
||||||
|
"title": "Samples",
|
||||||
|
"description": "Basic run statistics"})
|
||||||
|
client_fields: dict[str, Any] | None = field(
|
||||||
|
default_factory=dict, metadata={
|
||||||
|
"title": "Client fields",
|
||||||
|
"description": "Arbitrary key-value pairs provided by the client"})
|
||||||
|
|
||||||
|
def to_json(self, filename):
|
||||||
|
"""Save class as JSON."""
|
||||||
|
with open(filename, 'w') as f:
|
||||||
|
json.dump(asdict(self), f, default=str, indent=2, cls=DecimalEncoder)
|
||||||
|
|
||||||
|
|
||||||
|
class DecimalEncoder(json.JSONEncoder):
|
||||||
|
"""This should probably be moved."""
|
||||||
|
|
||||||
|
def default(self, obj):
|
||||||
|
"""Override the default method to handle Decimal objects."""
|
||||||
|
if isinstance(obj, Decimal):
|
||||||
|
return float(obj)
|
||||||
|
return super().default(obj)
|
||||||
@ -1,8 +1,11 @@
|
|||||||
"""The odd helper function."""
|
"""The odd helper function.
|
||||||
|
|
||||||
|
Be careful what you place in here. This file is imported into all glue.
|
||||||
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
|
||||||
_log_name = None
|
_log_name = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1
bin/workflow_glue/wfg_helpers/__init__.py
Normal file
1
bin/workflow_glue/wfg_helpers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""A collection of helper scripts common to workflows."""
|
||||||
@ -5,7 +5,7 @@ import sys
|
|||||||
|
|
||||||
import pysam
|
import pysam
|
||||||
|
|
||||||
from .util import get_named_logger, wf_parser # noqa: ABS101
|
from ..util import get_named_logger, wf_parser # noqa: ABS101
|
||||||
|
|
||||||
|
|
||||||
def main(args):
|
def main(args):
|
||||||
@ -5,7 +5,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .util import get_named_logger, wf_parser # noqa: ABS101
|
from ..util import get_named_logger, wf_parser # noqa: ABS101
|
||||||
|
|
||||||
|
|
||||||
# Some Excel users save their CSV as UTF-8 (and occasionally for a reason beyond my
|
# Some Excel users save their CSV as UTF-8 (and occasionally for a reason beyond my
|
||||||
@ -106,6 +106,13 @@ def main(args):
|
|||||||
sys.stdout.write("values in 'barcode' column are incorrect format")
|
sys.stdout.write("values in 'barcode' column are incorrect format")
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|
||||||
|
# check aliases are correct format
|
||||||
|
# for now we have decided they may not start with "barcode"
|
||||||
|
for alias in aliases:
|
||||||
|
if alias.startswith("barcode"):
|
||||||
|
sys.stdout.write("values in 'alias' column must not begin with 'barcode'")
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
# check barcodes are all the same length
|
# check barcodes are all the same length
|
||||||
first_length = len(barcodes[0])
|
first_length = len(barcodes[0])
|
||||||
for barcode in barcodes[1:]:
|
for barcode in barcodes[1:]:
|
||||||
@ -5,7 +5,7 @@ import sys
|
|||||||
|
|
||||||
import pysam
|
import pysam
|
||||||
|
|
||||||
from .util import get_named_logger, wf_parser # noqa: ABS101
|
from ..util import get_named_logger, wf_parser # noqa: ABS101
|
||||||
|
|
||||||
|
|
||||||
def validate_xam_index(xam_file):
|
def validate_xam_index(xam_file):
|
||||||
@ -4,7 +4,7 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .util import get_named_logger, wf_parser # noqa: ABS101
|
from ..util import get_named_logger, wf_parser # noqa: ABS101
|
||||||
|
|
||||||
|
|
||||||
# Common variables
|
# Common variables
|
||||||
@ -5,7 +5,7 @@ import sys
|
|||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from .util import get_named_logger, wf_parser # noqa: ABS101
|
from ..util import get_named_logger, wf_parser # noqa: ABS101
|
||||||
|
|
||||||
|
|
||||||
def main(args):
|
def main(args):
|
||||||
@ -52,9 +52,10 @@ the PG header. This script is a little overkill but attempts to be robust
|
|||||||
with handling PG collisions and more obviously encapsulates reheadering
|
with handling PG collisions and more obviously encapsulates reheadering
|
||||||
behaviour, and leaves some room to do more clever things as necessary.
|
behaviour, and leaves some room to do more clever things as necessary.
|
||||||
"""
|
"""
|
||||||
|
from shutil import copyfileobj
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .util import wf_parser # noqa: ABS101
|
from ..util import wf_parser # noqa: ABS101
|
||||||
|
|
||||||
|
|
||||||
class SamHeader:
|
class SamHeader:
|
||||||
@ -106,15 +107,12 @@ class SamHeader:
|
|||||||
if record_type in ["@HD", "@CO", "@SQ"]:
|
if record_type in ["@HD", "@CO", "@SQ"]:
|
||||||
return record_type, record_data
|
return record_type, record_data
|
||||||
elif record_type in ["@RG", "@PG"]:
|
elif record_type in ["@RG", "@PG"]:
|
||||||
allowed_keys = {
|
|
||||||
"@RG": ["ID", "BC", "CN", "DS", "DT", "FO", "KS", "LB", "PG", "PI", "PL", "PM", "PU", "SM"], # noqa:E501
|
|
||||||
"@PG": ["ID", "PN", "CL", "PP", "DS", "VN"]
|
|
||||||
}
|
|
||||||
for field in record_data.strip().split('\t'):
|
for field in record_data.strip().split('\t'):
|
||||||
k, v = field.split(':', 1)
|
k, v = field.split(':', 1)
|
||||||
if k not in allowed_keys[record_type]:
|
if len(k) == 2 and k[0].isalpha() and k[1].isalnum():
|
||||||
raise Exception(f"{record_type} with bad key '{k}': {record_data}")
|
|
||||||
record[k] = v
|
record[k] = v
|
||||||
|
else:
|
||||||
|
raise Exception(f"{record_type} with invalid tag: '{k}'")
|
||||||
if "ID" not in record:
|
if "ID" not in record:
|
||||||
raise Exception(f"{record_type} with no ID: {record_data}")
|
raise Exception(f"{record_type} with no ID: {record_data}")
|
||||||
return record_type, record
|
return record_type, record
|
||||||
@ -273,9 +271,29 @@ def reheader_samstream(header_in, stream_in, stream_out, args):
|
|||||||
break
|
break
|
||||||
sh.add_line(line)
|
sh.add_line(line)
|
||||||
|
|
||||||
# Pass through the rest of the alignments
|
# Pass through the rest of the alignments.
|
||||||
for line in stream_in:
|
# I toyed with a few ways of doing this:
|
||||||
stream_out.write(line)
|
# - A trivial iter over the input file was slow. presumably as we incurred some
|
||||||
|
# overhead calling read() and write() and decoding more than other methods.
|
||||||
|
# - os.read/write avoids dealing with higher level python read/write but requires
|
||||||
|
# file descriptors which rules out non-file-like objects. this made testing more
|
||||||
|
# annoying as StringIO does not have a file descriptor. we could have mocked fds
|
||||||
|
# but i was not happy with the discrepancy between real and test execution.
|
||||||
|
# - copyfileobj with the stream_in.buffer would also avoid some of the higher
|
||||||
|
# level text handling but would require all tests to provide inputs that have
|
||||||
|
# an underlying binary buffer. it was also not possible to seek the buffer to
|
||||||
|
# the position of the text stream as we've used next() to iterate over the
|
||||||
|
# header lines, fixing this would have required rewriting of the header
|
||||||
|
# handling or keeping track of the position in the stream ourselves which
|
||||||
|
# just seemed unncessary overkill given how we expect this program to be used.
|
||||||
|
# copyfileobj on the text streams is more efficient than merely iterating the file
|
||||||
|
# and dumping the lines out and seems to do the job. this keeps the code and tests
|
||||||
|
# simple with minimal additional cost to performance. i anticipate any overhead of
|
||||||
|
# this program will be dwarfed by that of minimap2/samtools sort anyway.
|
||||||
|
# increasing the buffer size gave worse performance in my limited testing so we
|
||||||
|
# leave it as the default here.
|
||||||
|
copyfileobj(stream_in, stream_out)
|
||||||
|
|
||||||
# If there were no alignments, we won't have hit the != @ case in the first stdin,
|
# If there were no alignments, we won't have hit the != @ case in the first stdin,
|
||||||
# and we won't have written the header out. Write a header if we haven't already.
|
# and we won't have written the header out. Write a header if we haven't already.
|
||||||
if not wrote_header:
|
if not wrote_header:
|
||||||
@ -191,13 +191,14 @@ class NfcoreSchema {
|
|||||||
if (unexpectedParams.size() > 0) {
|
if (unexpectedParams.size() > 0) {
|
||||||
Map colors = NfcoreTemplate.logColours(params.monochrome_logs)
|
Map colors = NfcoreTemplate.logColours(params.monochrome_logs)
|
||||||
println ''
|
println ''
|
||||||
def warn_msg = 'Found unexpected parameters:'
|
def error_msg = 'Found unexpected parameters:'
|
||||||
for (unexpectedParam in unexpectedParams) {
|
for (unexpectedParam in unexpectedParams) {
|
||||||
warn_msg = warn_msg + "\n* --${unexpectedParam}: ${params[unexpectedParam].toString()}"
|
error_msg = error_msg + "\n* --${unexpectedParam}: ${params[unexpectedParam].toString()}"
|
||||||
}
|
}
|
||||||
log.warn warn_msg
|
log.error error_msg
|
||||||
log.info "${colors.dim}- Ignore this warning: params.schema_ignore_params = \"${unexpectedParams.join(',')}\" ${colors.reset}"
|
log.info "${colors.dim}- Ignore unexpected params: params.schema_ignore_params = \"${unexpectedParams.join(',')}\" ${colors.reset}"
|
||||||
println ''
|
println ''
|
||||||
|
has_error = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (has_error) {
|
if (has_error) {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ process getParams {
|
|||||||
output:
|
output:
|
||||||
path "params.json"
|
path "params.json"
|
||||||
script:
|
script:
|
||||||
def paramsJSON = new JsonBuilder(params).toPrettyString()
|
def paramsJSON = new JsonBuilder(params).toPrettyString().replaceAll("'", "'\\\\''")
|
||||||
"""
|
"""
|
||||||
# Output nextflow params object to JSON
|
# Output nextflow params object to JSON
|
||||||
echo '$paramsJSON' > params.json
|
echo '$paramsJSON' > params.json
|
||||||
|
|||||||
340
lib/ingress.nf
340
lib/ingress.nf
@ -2,12 +2,6 @@ import java.nio.file.NoSuchFileException
|
|||||||
|
|
||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
|
|
||||||
enum InputType {
|
|
||||||
SingleFile,
|
|
||||||
TopLevelDir,
|
|
||||||
DirWithSubDirs,
|
|
||||||
}
|
|
||||||
|
|
||||||
N_OPEN_FILES_LIMIT = 128
|
N_OPEN_FILES_LIMIT = 128
|
||||||
|
|
||||||
|
|
||||||
@ -23,6 +17,28 @@ def is_target_file(Path file, List extensions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file path is flagged for exclusion.
|
||||||
|
*
|
||||||
|
* @param p: path to the file in question
|
||||||
|
* @param margs: map of ingress args
|
||||||
|
* @return: boolean whether the file should be excluded by ingress
|
||||||
|
*/
|
||||||
|
def is_excluded(Path p, Map margs) {
|
||||||
|
// filter target files for unclassified and failed directories
|
||||||
|
def this_path_parts = p.parent.toString().split(File.separator);
|
||||||
|
def this_unclassified = this_path_parts.contains("unclassified")
|
||||||
|
def this_fail = this_path_parts.contains("pod5_fail") || this_path_parts.contains("bam_fail") || this_path_parts.contains("fastq_fail")
|
||||||
|
|
||||||
|
def filter_unclassified = this_unclassified && !margs.analyse_unclassified
|
||||||
|
def filter_fail = this_fail && !margs.analyse_fail
|
||||||
|
|
||||||
|
// this function exits true and this file will be flagged for exclusion if
|
||||||
|
// any of the exclusion criteria is true
|
||||||
|
filter_unclassified || filter_fail
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Take a channel of the shape `[meta, reads, path-to-stats-dir | null]` (or
|
* Take a channel of the shape `[meta, reads, path-to-stats-dir | null]` (or
|
||||||
* `[meta, [reads, index], path-to-stats-dir | null]` in the case of XAM) and extract the
|
* `[meta, [reads, index], path-to-stats-dir | null]` in the case of XAM) and extract the
|
||||||
@ -40,10 +56,10 @@ def add_run_IDs_and_basecall_models_to_meta(ch, boolean allow_multiple_basecall_
|
|||||||
// as `ingressed_run_ids`
|
// as `ingressed_run_ids`
|
||||||
ch = ch | map { meta, reads, stats ->
|
ch = ch | map { meta, reads, stats ->
|
||||||
if (stats) {
|
if (stats) {
|
||||||
run_ids = stats.resolve("run_ids").splitText().collect { it.strip() }
|
def run_ids = stats.resolve("run_ids").splitText().collect { it.strip() }
|
||||||
ingressed_run_ids += run_ids
|
ingressed_run_ids += run_ids
|
||||||
|
|
||||||
basecall_models = \
|
def basecall_models = \
|
||||||
stats.resolve("basecallers").splitText().collect { it.strip() }
|
stats.resolve("basecallers").splitText().collect { it.strip() }
|
||||||
// check if we got more than one basecall model and set reads + stats to
|
// check if we got more than one basecall model and set reads + stats to
|
||||||
// `null` for that sample unless `allow_multiple_basecall_models`
|
// `null` for that sample unless `allow_multiple_basecall_models`
|
||||||
@ -270,7 +286,7 @@ def xam_ingress(Map arguments)
|
|||||||
|
|
||||||
// check BAM headers to see if any samples are uBAM
|
// check BAM headers to see if any samples are uBAM
|
||||||
ch_result = input.dirs
|
ch_result = input.dirs
|
||||||
| map { meta, path -> [meta, get_target_files_in_dir(path, xam_extensions)] }
|
| map { meta, path -> [meta, get_target_files_in_dir(path, xam_extensions, margs)] }
|
||||||
| mix(input.files)
|
| mix(input.files)
|
||||||
| map{
|
| map{
|
||||||
// If there is more than one BAM in each folder we ignore
|
// If there is more than one BAM in each folder we ignore
|
||||||
@ -576,7 +592,7 @@ process fastcat {
|
|||||||
|
|
||||||
# Save file as compressed fastq
|
# Save file as compressed fastq
|
||||||
fastcat \
|
fastcat \
|
||||||
-s ${meta["alias"]} \
|
-s '${meta["alias"].replaceAll("'","'\\\\''")}' \
|
||||||
-f fastcat_stats/per-file-stats.tsv \
|
-f fastcat_stats/per-file-stats.tsv \
|
||||||
-i fastcat_stats/per-file-runids.tsv \
|
-i fastcat_stats/per-file-runids.tsv \
|
||||||
-l fastcat_stats/per-file-basecallers.tsv \
|
-l fastcat_stats/per-file-basecallers.tsv \
|
||||||
@ -767,18 +783,18 @@ def watch_path(Path input, Map margs, ArrayList extensions) {
|
|||||||
// directory and (ii) files being generated in sub-directories. If we find files of
|
// directory and (ii) files being generated in sub-directories. If we find files of
|
||||||
// both kinds, throw an error.
|
// both kinds, throw an error.
|
||||||
if (input.isFile()) {
|
if (input.isFile()) {
|
||||||
error "Input ($input) must be a directory when using `watch_path`."
|
error "Input ($input) must be a folder when using `watch_path`."
|
||||||
}
|
}
|
||||||
// get existing target files first (look for relevant files in the top-level dir and
|
// get existing target files first (look for relevant files in the top-level dir and
|
||||||
// all sub-dirs)
|
// all sub-dirs)
|
||||||
def ch_existing_input = Channel.fromPath(input)
|
def ch_existing_input = Channel.fromPath(input)
|
||||||
| concat(Channel.fromPath("$input/*", type: 'dir'))
|
| concat(Channel.fromPath("$input/*", type: 'dir'))
|
||||||
| map { get_target_files_in_dir(it, extensions) }
|
| map { get_target_files_in_dir(it, extensions, margs, recursive=false) }
|
||||||
| flatten
|
| flatten
|
||||||
// now get channel with files found by `watchPath`
|
// now get channel with files found by `watchPath`
|
||||||
def ch_watched = Channel.watchPath("$input/**").until { it.name.startsWith('STOP') }
|
def ch_watched = Channel.watchPath("$input/**").until { it.name.startsWith('STOP') }
|
||||||
// only keep target files
|
// only keep target files
|
||||||
| filter { is_target_file(it, extensions) }
|
| filter { is_target_file(it, extensions) && !is_excluded(it, margs) }
|
||||||
// merge the channels
|
// merge the channels
|
||||||
ch_watched = ch_existing_input | concat(ch_watched)
|
ch_watched = ch_existing_input | concat(ch_watched)
|
||||||
// check if input is as expected; start by throwing an error when finding files in
|
// check if input is as expected; start by throwing an error when finding files in
|
||||||
@ -788,7 +804,7 @@ def watch_path(Path input, Map margs, ArrayList extensions) {
|
|||||||
| map {
|
| map {
|
||||||
String input_type = (it.parent == input) ? "top-level" : "sub-dir"
|
String input_type = (it.parent == input) ? "top-level" : "sub-dir"
|
||||||
if (prev_input_type && (input_type != prev_input_type)) {
|
if (prev_input_type && (input_type != prev_input_type)) {
|
||||||
error "`watchPath` found input files in the top-level directory " +
|
error "`watchPath` found input files in the top-level folder " +
|
||||||
"as well as in sub-directories."
|
"as well as in sub-directories."
|
||||||
}
|
}
|
||||||
// if file is in a sub-dir, make sure it's not a sub-sub-dir
|
// if file is in a sub-dir, make sure it's not a sub-sub-dir
|
||||||
@ -798,7 +814,7 @@ def watch_path(Path input, Map margs, ArrayList extensions) {
|
|||||||
}
|
}
|
||||||
// we also don't want files in the top-level dir when we got a sample sheet
|
// we also don't want files in the top-level dir when we got a sample sheet
|
||||||
if ((input_type == "top-level") && margs["sample_sheet"]) {
|
if ((input_type == "top-level") && margs["sample_sheet"]) {
|
||||||
error "`watchPath` found input files in top-level directory even though " +
|
error "`watchPath` found input files in top-level folder even though " +
|
||||||
"a sample sheet was provided ('${margs["sample_sheet"]}')."
|
"a sample sheet was provided ('${margs["sample_sheet"]}')."
|
||||||
}
|
}
|
||||||
prev_input_type = input_type
|
prev_input_type = input_type
|
||||||
@ -819,7 +835,7 @@ def watch_path(Path input, Map margs, ArrayList extensions) {
|
|||||||
Map sample_sheet_entry = sample_sheet_map[barcode]
|
Map sample_sheet_entry = sample_sheet_map[barcode]
|
||||||
// throw error if the barcode was not in the sample sheet
|
// throw error if the barcode was not in the sample sheet
|
||||||
if (!sample_sheet_entry) {
|
if (!sample_sheet_entry) {
|
||||||
error "Sub-directory $barcode was not found in the sample sheet."
|
error "Sub-folder $barcode was not found in the sample sheet."
|
||||||
}
|
}
|
||||||
[create_metamap(sample_sheet_entry), file_path]
|
[create_metamap(sample_sheet_entry), file_path]
|
||||||
}
|
}
|
||||||
@ -908,6 +924,7 @@ Map parse_arguments(String func_name, Map arguments, Map extra_kwargs=[:]) {
|
|||||||
"sample": null,
|
"sample": null,
|
||||||
"sample_sheet": null,
|
"sample_sheet": null,
|
||||||
"analyse_unclassified": false,
|
"analyse_unclassified": false,
|
||||||
|
"analyse_fail": false,
|
||||||
"stats": true,
|
"stats": true,
|
||||||
"required_sample_types": [],
|
"required_sample_types": [],
|
||||||
"watch_path": false,
|
"watch_path": false,
|
||||||
@ -939,65 +956,202 @@ Map parse_arguments(String func_name, Map arguments, Map extra_kwargs=[:]) {
|
|||||||
def get_valid_inputs(Map margs, ArrayList extensions){
|
def get_valid_inputs(Map margs, ArrayList extensions){
|
||||||
log.info "Searching input for $extensions files."
|
log.info "Searching input for $extensions files."
|
||||||
Path input
|
Path input
|
||||||
|
|
||||||
|
// check input path exists
|
||||||
try {
|
try {
|
||||||
input = file(margs.input, checkIfExists: true)
|
input = file(margs.input, checkIfExists: true)
|
||||||
} catch (NoSuchFileException e) {
|
} catch (NoSuchFileException e) {
|
||||||
error "Input path $margs.input does not exist."
|
error "Input path $margs.input does not exist."
|
||||||
}
|
}
|
||||||
|
|
||||||
// declare resulting input channel
|
// declare resulting input channel
|
||||||
def ch_input
|
def ch_input
|
||||||
|
|
||||||
// run `watchPath` if requested
|
// run `watchPath` if requested
|
||||||
if (margs["watch_path"]) {
|
if (margs["watch_path"]) {
|
||||||
ch_input = watch_path(input, margs, extensions)
|
ch_input = watch_path(input, margs, extensions)
|
||||||
} else {
|
|
||||||
// check which of the allowed input types (single file, top-lvl dir, dir with
|
// otherwise, easy case is this a file?
|
||||||
// sub-dirs) we got
|
} else if (input.isFile()) {
|
||||||
InputType input_type = determine_input_type(
|
if (!is_target_file(input, extensions)) {
|
||||||
input, extensions, margs.analyse_unclassified
|
error "Input file is not of required file type."
|
||||||
)
|
}
|
||||||
// handle case of `input` being a single file
|
|
||||||
if (input_type == InputType.SingleFile) {
|
|
||||||
ch_input = Channel.of(
|
ch_input = Channel.of(
|
||||||
[create_metamap([alias: margs["sample"] ?: input.simpleName]), input])
|
[create_metamap([alias: margs["sample"] ?: input.simpleName]), input])
|
||||||
} else if (input_type == InputType.TopLevelDir) {
|
|
||||||
// input is a directory containing target files
|
// before we handle a directory, check the path is not something ...weird
|
||||||
|
} else if (!input.isDirectory()){
|
||||||
|
error "Input $input appears to be neither a file nor a folder."
|
||||||
|
|
||||||
|
// we're a directory and one of three cases applies
|
||||||
|
// (i) a single directory with only target files (old case 2)
|
||||||
|
// (ii) multiple directories with only target files (eg. demultiplexed barcodes - old case 3)
|
||||||
|
// (iii) an arbitrarily nested directory layout (eg. MinKNOW experiment - new case 4)
|
||||||
|
} else {
|
||||||
|
// work out what we're dealing with:
|
||||||
|
// - iterate over all target files in the tree
|
||||||
|
// - ignoring (or including) unclassified and failures as required
|
||||||
|
// - check the depth of each file (by counting the number of components in its path)
|
||||||
|
// - all files must have the same depth
|
||||||
|
// - if all files also have the same depth as the input dir
|
||||||
|
// then this is a simple case of a single directory of files
|
||||||
|
// - if all files have depth + 1, then this is the case 3 case
|
||||||
|
Boolean is_singleplex_dir = true
|
||||||
|
Boolean is_multiplex_dir = true
|
||||||
|
Integer input_depth = input.toString().count(File.separator)
|
||||||
|
String this_parent
|
||||||
|
String first_parent
|
||||||
|
Integer this_depth
|
||||||
|
Integer first_depth
|
||||||
|
|
||||||
|
// enumerate all valid files and check their depths
|
||||||
|
// this is not responsible for returning the list of files
|
||||||
|
// this is done regardless of case, as singleplex, multiplex and experiment dirs have the same requirement
|
||||||
|
ArrayList all_files = get_target_files_in_dir(input, extensions, margs)
|
||||||
|
.each {
|
||||||
|
this_parent = it.parent.toString()
|
||||||
|
this_depth = this_parent.count(File.separator)
|
||||||
|
if (first_depth == null) {
|
||||||
|
first_depth = this_depth
|
||||||
|
first_parent = this_parent
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// this file has different depth from first file - abort accordingly
|
||||||
|
if (this_depth != first_depth) {
|
||||||
|
error "Found files at different levels in your input folder:\n* ${this_parent}\n* ${first_parent}\n\nAll files in the input folder must be at the same folder level. Please reorganise and try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// this file has different depth from the input directory path - we're not in the single directory of files case
|
||||||
|
if (this_depth != input_depth) {
|
||||||
|
is_singleplex_dir = false
|
||||||
|
}
|
||||||
|
// this file has different depth from the input directory path + 1 - we're not in the multiplex directory case
|
||||||
|
if (this_depth != (input_depth + 1)) {
|
||||||
|
is_multiplex_dir = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we are neither singleplex (case 2), nor multiplex (case 3), we must be an experiment dir (case 4)
|
||||||
|
// a sample sheet or sample name is required to ensure we ingest the right data
|
||||||
|
Boolean is_experimental_dir = !(is_singleplex_dir || is_multiplex_dir)
|
||||||
|
if (is_experimental_dir) {
|
||||||
|
if (!(margs.sample_sheet || margs.sample)) {
|
||||||
|
error "Sample sheet or sample name must be provided."
|
||||||
|
}
|
||||||
|
if (extensions[0] == ".fastq") {
|
||||||
|
// nextflow is used to manage the BAM files sent to bamstats/xam_ingress
|
||||||
|
// however, fastcat is used to manage FASTQ files directly, meaning it does not support analyse_unclassified,analyse_fail in the same way
|
||||||
|
// we'll avoid support for it for now
|
||||||
|
// see CW-5613
|
||||||
|
error "FASTQ input not currently supported when ingressing MinKNOW experiment folder."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// define string to re-use in error messages below
|
||||||
|
String target_files_str = \
|
||||||
|
"${extensions.collect{'\'' + it + '\''}.join(' / ')}"
|
||||||
|
|
||||||
|
// cry for help if there are no target files
|
||||||
|
if (all_files.size() == 0) {
|
||||||
|
error "No valid files ending in ${target_files_str} found in input folder '${input}'."
|
||||||
|
|
||||||
|
// input is a simple single top level directory containing target files
|
||||||
|
} else if (is_singleplex_dir) {
|
||||||
ch_input = Channel.of(
|
ch_input = Channel.of(
|
||||||
[create_metamap([alias: margs["sample"] ?: input.baseName]), input])
|
[create_metamap([alias: margs["sample"] ?: input.baseName]), input])
|
||||||
|
|
||||||
|
// otherwise we're looking at a directory tree
|
||||||
} else {
|
} else {
|
||||||
// input is a directory with sub-directories (e.g. barcodes) containing
|
// input is a directory with sub-directories (e.g. barcodes/aliases)
|
||||||
// target files --> find these sub-directories
|
// with zero or more further sub-directories
|
||||||
|
// resolve with * to find the first level subdirs and filter out
|
||||||
|
// any entries that do not have any target files
|
||||||
ArrayList sub_dirs_with_target_files = file(
|
ArrayList sub_dirs_with_target_files = file(
|
||||||
input.resolve('*'), type: "dir"
|
input.resolve('*'), type: "dir"
|
||||||
).findAll { get_target_files_in_dir(it, extensions) }
|
).findAll { get_target_files_in_dir(it, extensions, margs) }
|
||||||
// remove directories called 'unclassified' unless otherwise specified
|
|
||||||
if (!margs.analyse_unclassified) {
|
// filter ingressed dirs to named sample - no sample sheet
|
||||||
sub_dirs_with_target_files = sub_dirs_with_target_files.findAll {
|
if (margs.sample && !margs.sample_sheet) {
|
||||||
it.baseName != "unclassified"
|
ch_input = Channel.fromPath(sub_dirs_with_target_files).map {
|
||||||
|
if(it.baseName == margs.sample) {
|
||||||
|
[create_metamap([alias: it.baseName, barcode: it.baseName]), it]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.warn "Ignoring $it.baseName: Found in input folder but does not match sample name provided ($margs.sample)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// filter based on sample sheet in case one was provided
|
}
|
||||||
if (margs.sample_sheet) {
|
else if (margs.sample_sheet) {
|
||||||
// get channel of entries in the sample sheet
|
// get channel of entries in the sample sheet
|
||||||
def ch_sample_sheet = get_sample_sheet(
|
def ch_sample_sheet = get_sample_sheet(
|
||||||
file(margs.sample_sheet), margs.required_sample_types
|
file(margs.sample_sheet), margs.required_sample_types
|
||||||
)
|
)
|
||||||
// get the union of both channels (missing values will be replaced with
|
|
||||||
// `null`)
|
// Divide samples into barcoded and aliased,
|
||||||
def ch_union = Channel.fromPath(sub_dirs_with_target_files).map {
|
// we'll join these to the sample sheet individually
|
||||||
[it.baseName, it]
|
ch_samples = Channel.fromPath(sub_dirs_with_target_files)
|
||||||
}.join(ch_sample_sheet.map{[it.barcode, it]}, remainder: true)
|
| map { [it.baseName, it] }
|
||||||
|
| branch { basename, path ->
|
||||||
|
barcoded: basename.startsWith("barcode")
|
||||||
|
aliased: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join barcoded samples to sample sheet, remove entries that do not match to sheet and warn accordingly
|
||||||
|
// after join. Yields [basename, path (if joined), alias, sample_sheet_row] for samples on disk and sample sheet,
|
||||||
|
// otherwise yields [basename, path, null] for samples missing a sample sheet entry, we'll prune these out
|
||||||
|
// by looking for a null alias (ie. no sample sheet entry) to prevent a join error on ch_union below.
|
||||||
|
ch_samples_barcoded = ch_samples.barcoded
|
||||||
|
| join(ch_sample_sheet.map{ [it.barcode, it.alias, it] }, remainder:true)
|
||||||
|
| map {
|
||||||
|
if (it[2]) { it }
|
||||||
|
else { log.warn "Ignoring ${it[0]}: Found in input folder but sample sheet has no such entry." }
|
||||||
|
}
|
||||||
|
// repeat the above for aliased samples
|
||||||
|
ch_samples_aliased = ch_samples.aliased
|
||||||
|
| join(ch_sample_sheet.map{ [it.alias, it.alias, it] }, remainder:true)
|
||||||
|
| map {
|
||||||
|
if (it[2]) { it }
|
||||||
|
else { log.warn "Ignoring ${it[0]}: Found in input folder but sample sheet has no such entry." }
|
||||||
|
}
|
||||||
|
|
||||||
|
// It is now safe to join (on alias) the barcode and alias samples together as we've removed entries that conflict with the sample sheet.
|
||||||
|
// The ch_union channel will now have an element for each row of the sample sheet
|
||||||
|
// combining the barcode and alias information and any paths for either that were matched on disk
|
||||||
|
ch_union = ch_samples_barcoded.join(ch_samples_aliased, by:2)
|
||||||
|
|
||||||
// after joining the channels, there are three possible cases:
|
// after joining the channels, there are three possible cases:
|
||||||
// (i) valid input path and sample sheet entry are both present
|
// (i) valid input path for ONE of barcode and alias, and its sample sheet entry is present
|
||||||
|
// --> we'll emit `[metamap-from-sample-sheet-entry, path]`
|
||||||
// (ii) there is a sample sheet entry but no corresponding input dir
|
// (ii) there is a sample sheet entry but no corresponding input dir
|
||||||
// --> we'll emit `[metamap-from-sample-sheet-entry, null]`
|
// --> we'll emit `[metamap-from-sample-sheet-entry, null]`
|
||||||
// (iii) there is a valid path, but the sample sheet entry is missing
|
// (iii) valid input path for BOTH barcode and alias, and its sample sheet entry are present
|
||||||
// --> drop this entry and print a warning to the log
|
// --> a directory for both the barcode and alias have been provided
|
||||||
ch_input = ch_union.map {barcode, path, sample_sheet_entry ->
|
// and we don't know which to pick, so we'll raise an error for this conflict
|
||||||
if (sample_sheet_entry) {
|
// * sample_sheet_entry will be set here as we've filtered out those cases above
|
||||||
|
// * _alias and _sample_sheet_entry and merely unused dupes of alias and sample_sheet_entry due to the ch_union join
|
||||||
|
ch_input = ch_union.map {alias, barcode, barcode_path, sample_sheet_entry, _alias, alias_path, _sample_sheet_entry ->
|
||||||
|
def path = null
|
||||||
|
if (barcode_path && alias_path){
|
||||||
|
error "Found conflicting folders and cannot ingress both sample folder '$alias' and barcode folder '$barcode' for same sample sheet row."
|
||||||
|
}
|
||||||
|
else if (barcode_path || alias_path) {
|
||||||
|
path = barcode_path ?: alias_path
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
log.warn "Ignoring $alias: Found in sample sheet but a corresponding sample folder was not found in the input folder."
|
||||||
|
}
|
||||||
|
if(margs.sample) {
|
||||||
|
if (alias == margs.sample || barcode == margs.sample) {
|
||||||
|
[create_metamap(sample_sheet_entry), path]
|
||||||
|
}
|
||||||
|
else if (path) {
|
||||||
|
// only emit "found in input folder" if a path exists
|
||||||
|
log.warn "Ignoring $alias: Found in input folder and sample sheet, but does not match sample name provided ($margs.sample)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
[create_metamap(sample_sheet_entry), path]
|
[create_metamap(sample_sheet_entry), path]
|
||||||
} else {
|
|
||||||
log.warn "Input directory '$barcode' was found, but sample " +
|
|
||||||
"sheet '$margs.sample_sheet' has no such entry."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -1008,19 +1162,23 @@ def get_valid_inputs(Map margs, ArrayList extensions){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// finally, we "unwrap" directories containing only a single file and then split the
|
// unwrap folders containing a single target file into a channel for just that file
|
||||||
// results channel into the three different output types (sample sheet entries
|
// then return a branched channel containing:
|
||||||
// without corresponding barcodes -- i.e. with `path == null`, single files, and
|
// * missing - indicating sample sheet entries that were not matched to the input
|
||||||
// dirs with multiple files)
|
// directory, the meta is populated but the path is null
|
||||||
def ch_branched_results = ch_input.map { meta, path ->
|
// * files - single file inputs (including those from a directory with a single file)
|
||||||
|
// * dirs - directory inputs in need of munging downstream
|
||||||
|
def ch_branched_results = ch_input
|
||||||
|
| map { meta, path ->
|
||||||
if (path && path.isDirectory()) {
|
if (path && path.isDirectory()) {
|
||||||
List fq_files = get_target_files_in_dir(path, extensions)
|
List fq_files = get_target_files_in_dir(path, extensions, margs)
|
||||||
if (fq_files.size() == 1) {
|
if (fq_files.size() == 1) {
|
||||||
path = fq_files[0]
|
path = fq_files[0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
[meta, path]
|
[meta, path]
|
||||||
} .branch { meta, path ->
|
}
|
||||||
|
| branch { meta, path ->
|
||||||
missing: !path
|
missing: !path
|
||||||
files: path.isFile()
|
files: path.isFile()
|
||||||
dirs: path.isDirectory()
|
dirs: path.isDirectory()
|
||||||
@ -1028,68 +1186,6 @@ def get_valid_inputs(Map margs, ArrayList extensions){
|
|||||||
return ch_branched_results
|
return ch_branched_results
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine which of the allowed categories (single file, top-level directory, or
|
|
||||||
* directory with sub-directory) an input path belongs to.
|
|
||||||
*
|
|
||||||
* @param margs: parsed arguments (see `fastq_ingress()` or `xam_ingress()` for details)
|
|
||||||
* @param extensions: list of valid extensions for the target file type
|
|
||||||
* @return: input type represented as an instance of the `InputType` enum
|
|
||||||
*/
|
|
||||||
InputType determine_input_type(
|
|
||||||
Path input, ArrayList extensions, boolean analyse_unclassified
|
|
||||||
) {
|
|
||||||
if (input.isFile()) {
|
|
||||||
if (!is_target_file(input, extensions)) {
|
|
||||||
error "Input file is not of required file type."
|
|
||||||
}
|
|
||||||
return InputType.SingleFile
|
|
||||||
} else if (!input.isDirectory()){
|
|
||||||
error "Input $input appears to be neither a file nor a directory."
|
|
||||||
}
|
|
||||||
// `input` is a directory --> we accept two cases: (i) a top-level directory with
|
|
||||||
// target files and no sub-directories or (ii) a directory with one layer of
|
|
||||||
// sub-directories containing target files. First, check if the directory contains
|
|
||||||
// target files and find potential sub-directories (and sub-dirs with target files;
|
|
||||||
// note that these lists can be empty)
|
|
||||||
boolean dir_has_target_files = get_target_files_in_dir(input, extensions)
|
|
||||||
ArrayList sub_dirs = file(input.resolve('*'), type: "dir")
|
|
||||||
ArrayList sub_dirs_with_target_files = sub_dirs.findAll {
|
|
||||||
get_target_files_in_dir(it, extensions)
|
|
||||||
}.findAll { it.baseName != "unclassified" || analyse_unclassified }
|
|
||||||
|
|
||||||
// define string to re-use in error messages below
|
|
||||||
String target_files_str = \
|
|
||||||
"target files (ending in ${extensions.collect{'\'' + it + '\''}.join(' / ')})"
|
|
||||||
|
|
||||||
// check for target files in the top-level dir; if there are any, make sure there
|
|
||||||
// are no sub-directories containing target files
|
|
||||||
if (dir_has_target_files) {
|
|
||||||
if (sub_dirs_with_target_files) {
|
|
||||||
error "Input directory '$input' cannot contain $target_files_str " +
|
|
||||||
"and also sub-directories with such files."
|
|
||||||
}
|
|
||||||
return InputType.TopLevelDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// no target files in the top-level dir --> make sure there were sub-dirs with
|
|
||||||
// target files
|
|
||||||
if (!sub_dirs_with_target_files) {
|
|
||||||
error "Input directory '$input' must contain either $target_files_str " +
|
|
||||||
"or sub-directories containing such files (no more than one layer deep)."
|
|
||||||
}
|
|
||||||
// we don't allow sub-sub-directories with target files
|
|
||||||
if (sub_dirs.any {
|
|
||||||
ArrayList subsubdirs = file(it.resolve('*'), type: "dir")
|
|
||||||
subsubdirs.any { get_target_files_in_dir(it, extensions) }
|
|
||||||
}) {
|
|
||||||
error "Input directory '$input' cannot contain more " +
|
|
||||||
"than one level of sub-directories with $target_files_str."
|
|
||||||
}
|
|
||||||
return InputType.DirWithSubDirs
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a map that contains at least these keys: `[alias, barcode, type]`.
|
* Create a map that contains at least these keys: `[alias, barcode, type]`.
|
||||||
* `alias` is required, `barcode` and `type` are filled with default values if
|
* `alias` is required, `barcode` and `type` are filled with default values if
|
||||||
@ -1116,14 +1212,18 @@ Map create_metamap(Map arguments) {
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the target files in the directory (non-recursive).
|
* Get all target files below this directory.
|
||||||
*
|
*
|
||||||
* @param dir: path to the target directory
|
* @param dir: path to the target directory
|
||||||
* @param extensions: list of valid extensions for the target file type
|
* @param extensions: list of valid extensions for the target file type
|
||||||
|
* @param margs: ingress margs
|
||||||
* @return: list of found target files
|
* @return: list of found target files
|
||||||
*/
|
*/
|
||||||
ArrayList get_target_files_in_dir(Path dir, ArrayList extensions) {
|
ArrayList get_target_files_in_dir(Path dir, ArrayList extensions, Map margs, Boolean recursive = true) {
|
||||||
file(dir.resolve("*")).findAll { is_target_file(it, extensions) }
|
String resolver = recursive ? "**" : "*"
|
||||||
|
file(dir.resolve(resolver)).findAll {
|
||||||
|
is_target_file(it, extensions) && !is_excluded(it, margs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -73,6 +73,7 @@ params {
|
|||||||
plot_gffcmp_stats = true
|
plot_gffcmp_stats = true
|
||||||
|
|
||||||
disable_ping = false
|
disable_ping = false
|
||||||
|
store_dir = null
|
||||||
|
|
||||||
// de options
|
// de options
|
||||||
de_analysis = false
|
de_analysis = false
|
||||||
@ -95,7 +96,7 @@ params {
|
|||||||
]
|
]
|
||||||
agent = null
|
agent = null
|
||||||
container_sha = "shac733d952a14257cf3c5c5d5d44c6aed84d5fe5a1"
|
container_sha = "shac733d952a14257cf3c5c5d5d44c6aed84d5fe5a1"
|
||||||
common_sha = "shaabceef445fb63214073cbf5836fdd33c04be4ac7"
|
common_sha = "sha9ef2f4e4585c4ce6a604616e77185077551abf50"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -316,6 +316,10 @@
|
|||||||
"fa_icon": "fas fa-question-circle",
|
"fa_icon": "fas fa-question-circle",
|
||||||
"hidden": true
|
"hidden": true
|
||||||
},
|
},
|
||||||
|
"store_dir": {
|
||||||
|
"type": "string",
|
||||||
|
"hidden": true
|
||||||
|
},
|
||||||
"disable_ping": {
|
"disable_ping": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false,
|
"default": false,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user