Repository: marcelm/cutadapt

Issue:
Pipe multiple adatper trimming steps is unsupported when using the python API.
When using the API code in cutadapt, multiple adapter trimming steps will cause error. 

For example given a NGS sequencing library with the following scheme:

`(p5 adapter)-(UMI)-(inner adapter)-(insert sequence)-(p7 adapter)`

We need to cut `p5 adapter` by `-a`, then cut UMI sequence by `-u`, then cut inner adapter again by `-a`. Since `-a` is triggered twice, cutadapter will raise an error. 

https://github.com/marcelm/cutadapt/blob/33df54550e9d789e03fb9e10a98d18a2a8d71b94/src/cutadapt/report.py#L180







Comments:

Patch:
diff --git a/src/cutadapt/report.py b/src/cutadapt/report.py
index 84ff9e0..2b47d8e 100644
--- a/src/cutadapt/report.py
+++ b/src/cutadapt/report.py
@@ -177,16 +177,28 @@ class Statistics:
             if isinstance(modifier, PolyATrimmer):
                 self.poly_a_trimmed_lengths[i] = modifier.trimmed_bases
             elif isinstance(modifier, AdapterCutter):
-                assert self.with_adapters[i] is None
-                self.with_adapters[i] = modifier.with_adapters
-                self.adapter_stats[i] = list(modifier.adapter_statistics.values())
+                if self.with_adapters[i] is None:
+                    self.with_adapters[i] = modifier.with_adapters
+                    self.adapter_stats[i] = list(modifier.adapter_statistics.values())
+                else:
+                    self.with_adapters[i] += modifier.with_adapters  # type: ignore
+                    self.adapter_stats[i] += list(modifier.adapter_statistics.values())
             elif isinstance(modifier, ReverseComplementer):
-                assert self.with_adapters[i] is None
-                self.with_adapters[i] = modifier.adapter_cutter.with_adapters
-                self.adapter_stats[i] = list(
-                    modifier.adapter_cutter.adapter_statistics.values()
-                )
-                self.reverse_complemented = modifier.reverse_complemented
+                if self.with_adapters[i] is None:
+                    self.with_adapters[i] = modifier.adapter_cutter.with_adapters
+                    self.adapter_stats[i] = list(
+                        modifier.adapter_cutter.adapter_statistics.values()
+                    )
+                    self.reverse_complemented = modifier.reverse_complemented
+                else:
+                    assert self.with_adapters[i] is not None
+                    self.with_adapters[i] += modifier.adapter_cutter.with_adapters  # type: ignore
+                    self.adapter_stats[i] += list(
+                        modifier.adapter_cutter.adapter_statistics.values()
+                    )
+                    self.reverse_complemented = add_if_not_none(
+                        self.reverse_complemented, modifier.reverse_complemented
+                    )
 
     def as_json(self, gc_content: float = 0.5, one_line: bool = False) -> Dict:
         """


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index 1add55e..2df132b 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -154,3 +154,31 @@ def test_pipeline_paired(tmp_path, cores):
     # - too many submodules (flatter namespace)
     # - use xopen directly instead of file_opener;
     #   possibly with myxopen = functools.partial(xopen, ...)
+
+
+def test_two_adapter_cutters_and_reverse_complementer(tmp_path):
+    from cutadapt.pipeline import SingleEndPipeline
+    from cutadapt.files import OutputFiles, InputPaths
+    from cutadapt.modifiers import AdapterCutter, ReverseComplementer
+    from cutadapt.adapters import BackAdapter
+
+    adapter = BackAdapter(sequence="GATCGGAAGA")
+    modifiers = [
+        AdapterCutter([adapter]),
+        AdapterCutter([adapter]),
+        ReverseComplementer(AdapterCutter([adapter])),
+    ]
+    inpaths = InputPaths(datapath("small.fastq"))
+    with make_runner(inpaths, cores=1) as runner:
+        outfiles = OutputFiles(
+            proxied=False,
+            qualities=True,
+            interleaved=False,
+        )
+        steps = [SingleEndSink(outfiles.open_record_writer(tmp_path / "out.fastq"))]
+        pipeline = SingleEndPipeline(modifiers, steps)
+        stats = runner.run(pipeline, DummyProgress(), outfiles)
+    outfiles.close()
+
+    assert stats is not None
+    assert len(stats.as_json()["adapters_read1"]) == 3