Skip to content

Commit 5c3fc46

Browse files
committed
add subset - ya ni sé
1 parent 93de6a6 commit 5c3fc46

3 files changed

Lines changed: 150 additions & 21 deletions

File tree

atg/tools/app.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from pathlib import Path
22

33
import typer
4-
from psutil import cpu_count
4+
from multiprocessing import cpu_count
55

66
from atg.tools.ena import ena_download, ena_fields, ena_retrieve
77
from atg.tools.git import gitcheck
88
from atg.tools.manifest import create_manifest
9+
from atg.tools.sample import subsample_fastq_seqkit
910
from atg.utils import OrderCommands, count_fastq, get_abundance, one_liner
1011

1112
tools_app = typer.Typer(
@@ -190,3 +191,55 @@ def count_tools_command(
190191
def abundance_tools_command():
191192
"""Relative abundance tables"""
192193
print(get_abundance())
194+
195+
196+
@tools_app.command(name="subsample")
197+
def subsample_seqkit_tools_command(
198+
input_path: str = typer.Option(
199+
...,
200+
"--input",
201+
"-i",
202+
show_default=False,
203+
help="FASTQ file or directory containing FASTQ files (supports both single-end and paired-end)",
204+
),
205+
output_dir: str = typer.Option(
206+
...,
207+
"--output",
208+
"-o",
209+
show_default=False,
210+
help="Output directory for subsampled files",
211+
),
212+
proportion: float = typer.Option(
213+
0.1,
214+
"--proportion",
215+
"-p",
216+
min=0.0,
217+
max=1.0,
218+
help="Proportion of reads to subsample (0.0 to 1.0)",
219+
),
220+
seed: int = typer.Option(
221+
42,
222+
"--seed",
223+
"-s",
224+
help="Random seed for reproducible subsampling",
225+
),
226+
max_workers: int = typer.Option(
227+
cpu_count(),
228+
"--workers",
229+
"-w",
230+
help="Maximum number of worker threads for parallel processing",
231+
),
232+
):
233+
"""Create subsamples of FASTQ files with specified proportion using seqkit.
234+
235+
Supports both single-end (NAME.fastq.gz, NAME.fastq) and paired-end
236+
(NAME_R1.fastq.gz/NAME_R2.fastq.gz, NAME_1.fastq.gz/NAME_2.fastq.gz) files.
237+
Requires seqkit to be installed.
238+
"""
239+
subsample_fastq_seqkit(
240+
input_path=input_path,
241+
output_dir=output_dir,
242+
proportion=proportion,
243+
seed=seed,
244+
max_workers=max_workers,
245+
)

atg/tools/ena.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
import requests
99
from tabulate import tabulate
1010
from tqdm import tqdm
11-
from tqdm.contrib.concurrent import thread_map
1211
from urllib3 import util
12+
from loguru import logger
13+
14+
from atg.utils import thread_map_parallel
1315

1416
# ic.configureOutput(prefix=" -> ")
1517

@@ -45,16 +47,16 @@ def download_url(input_file: Tuple[str, str, Path]) -> None:
4547
shutil.copyfileobj(r_raw, file)
4648

4749
except requests.exceptions.Timeout:
48-
print(f"Timeout downloading {fname}. Please retry later.")
50+
logger.error(f"Timeout downloading {fname}. Please retry later.")
4951
raise
5052
except requests.exceptions.HTTPError as e:
51-
print(f"HTTP error downloading {fname}: {e}")
53+
logger.error(f"HTTP error downloading {fname}: {e}")
5254
raise
5355
except requests.exceptions.ConnectionError:
54-
print(f"Connection error downloading {fname}. Please check your internet connection.")
56+
logger.error(f"Connection error downloading {fname}. Please check your internet connection.")
5557
raise
5658
except Exception as e:
57-
print(f"Error downloading {fname}: {e}")
59+
logger.error(f"Error downloading {fname}: {e}")
5860
raise
5961

6062

@@ -102,10 +104,10 @@ def request_get(api: str, pdict: str, fields: str, safe: str = ",") -> pd.DataFr
102104

103105
return df
104106
except requests.exceptions.Timeout:
105-
print("Connection to the server has timed out. Please retry.")
107+
logger.error("Connection to the server has timed out. Please retry.")
106108
return None
107109
except requests.exceptions.HTTPError:
108-
print("HTTPError: This is likely caused by an invalid search query")
110+
logger.error("HTTPError: This is likely caused by an invalid search query")
109111
return None
110112

111113

@@ -129,7 +131,7 @@ def ena_fields(id_err: str, save: bool = True, fields: str = "") -> Dict[str, st
129131

130132
if save:
131133
df.to_csv(f"{id_err}.tsv", sep="\t", index=False)
132-
print(f"ENA metadata saved as {id_err}.tsv")
134+
logger.info(f"ENA metadata saved as {id_err}.tsv")
133135
else:
134136
pass
135137

@@ -155,8 +157,7 @@ def md5_hash(filename, block_size=2**20):
155157

156158
def thread_map_urls(url_dict: Dict[str, str], outdir: Path, cpu: int) -> None:
157159
iter_url = [(k, v, outdir) for k, v in url_dict.items()]
158-
if len(iter_url) > 0:
159-
thread_map(download_url, iter_url, max_workers=cpu)
160+
thread_map_parallel(download_url, iter_url, cpu)
160161

161162

162163
def checksums(id_err: str, output_dir: Path, file_lst: List[str], threads: int) -> None:
@@ -179,7 +180,7 @@ def compare_lists(df_md5: pd.DataFrame, test_list: List[str], outdir: Path, n_cp
179180
md5_failed = [k for k, v in urls_dict.items() if v[0] != md5_hash(output_dir / k)]
180181

181182
if compare_lists(dfmd5, md5_failed, output_dir, threads) is None:
182-
print("All files are already downloaded")
183+
logger.info("All files are already downloaded")
183184

184185

185186
def ena_search(
@@ -192,7 +193,7 @@ def ena_search(
192193
df = request_get("browser/api/tsv/textsearch", params, fields="all", safe=safe)
193194

194195
if df.empty:
195-
print("Check your query")
196+
logger.error("Check your query")
196197

197198
return df
198199

@@ -207,14 +208,14 @@ def ena_retrieve(keywords: str, save: bool, only_ids: bool):
207208

208209
if save and not only_ids:
209210
df.to_csv(f"{keywords}.tsv", sep="\t", index=False)
210-
print(f"ENA metadata saved as {keywords}.tsv")
211+
logger.info(f"ENA metadata saved as {keywords}.tsv")
211212
elif only_ids and not save:
212-
print(tabulate(df[["accession"]], headers="keys", showindex=False))
213+
logger.info(tabulate(df[["accession"]], headers="keys", showindex=False))
213214
elif only_ids and save:
214215
df["accession"].to_csv(f"{keywords}_ids.tsv", index=False, header=False)
215-
print(f"ENA metadata saved as {keywords}_ids.tsv")
216+
logger.info(f"ENA metadata saved as {keywords}_ids.tsv")
216217
else:
217-
print(tabulate(df, headers="keys", showindex=False, tablefmt="plain"))
218+
logger.info(tabulate(df, headers="keys", showindex=False, tablefmt="plain"))
218219

219220

220221
def ena_download(bioproject: str, cpus: int, fields: str = None, output_base_dir: Path = None) -> None:
@@ -230,7 +231,7 @@ def ena_download(bioproject: str, cpus: int, fields: str = None, output_base_dir
230231
files = [x.name for x in Path.glob(out_dir, "*.fastq.gz")]
231232

232233
if len(files) > 0:
233-
print("Verifying MD5 File Checksums...")
234+
logger.info("Verifying MD5 File Checksums...")
234235
checksums(err_id, out_dir, files, cpus)
235236
else:
236237
err_urls = ena_urls(ena_fields(id_err=err_id, fields=fields))

atg/utils.py

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
from enum import Enum
55
from functools import wraps
66
from pathlib import Path
7-
from typing import Any
7+
from typing import Any, Union, Dict, Callable, List
88

99
import anndata as ad
1010
import pyfastx
1111
from click import Context
1212
from loguru import logger
1313
from typer.core import TyperGroup
14+
from tqdm.contrib.concurrent import thread_map
1415

1516

1617
def timeit(f: Any) -> Any:
@@ -30,6 +31,18 @@ def wrapper(*args, **kargs): # type: ignore
3031
return wrapper
3132

3233

34+
def thread_map_parallel(func: Callable, data: List, max_workers: int) -> None:
35+
"""Execute function in parallel using thread_map.
36+
37+
Args:
38+
func: Function to execute
39+
data: List of data items to process
40+
max_workers: Maximum number of worker threads
41+
"""
42+
if len(data) > 0:
43+
thread_map(func, data, max_workers=max_workers, desc="Processing")
44+
45+
3346
def one_liner(input_fasta: str) -> None:
3447
"""
3548
Convert multiline FASTA to single line FASTA. The input file is overwritten.
@@ -103,13 +116,23 @@ def check_dir(fastq_dir: str) -> Path:
103116
return _fastq_dir
104117

105118

106-
def fastq_files(fastq: str, pattern: str) -> list:
119+
def fastq_files(fastq: str, pattern: str) -> dict:
120+
"""
121+
Get FASTQ files from directory or single file.
122+
123+
Args:
124+
fastq: Path to FASTQ file or directory
125+
pattern: Regex pattern to match files
126+
127+
Returns:
128+
dict: Dictionary mapping sample names to file paths
129+
"""
107130
if Path(fastq).is_file():
108131
fqfile = Path(fastq).name.partition(".")[0]
109132
return {fqfile: fastq}
110133

111134
check_dir(fastq)
112-
pattern = re.compile(r".*_([1-2]|R[1-2]).(fastq|fq)\.gz$")
135+
pattern = re.compile(pattern)
113136
fqfiles = sorted([x for x in Path(fastq).glob("*") if pattern.match(str(x))])
114137
snames = sorted([str(x.name.partition(".")[0]) for x in fqfiles])
115138

@@ -123,3 +146,55 @@ def count_fastq(fastq_file, pattern: str):
123146
index_file = Path(f"{str(v)}.fxi")
124147
if index_file.exists():
125148
index_file.unlink()
149+
150+
151+
def check_paired(fastq_files: dict) -> dict:
152+
"""
153+
Check if FASTQ files are paired-end based on naming patterns.
154+
155+
Args:
156+
fastq_files: Dictionary of FASTQ files from fastq_files function
157+
158+
Returns:
159+
dict: Dictionary with 'paired' and 'single' keys containing file lists
160+
"""
161+
paired_files = {}
162+
single_files = {}
163+
164+
# Common paired-end patterns (both compressed and uncompressed)
165+
paired_patterns = [
166+
(r'_R1\.(fastq|fq)(\.gz)?$', r'_R2\.(fastq|fq)(\.gz)?$'),
167+
(r'_1\.(fastq|fq)(\.gz)?$', r'_2\.(fastq|fq)(\.gz)?$'),
168+
(r'\.1\.(fastq|fq)(\.gz)?$', r'\.2\.(fastq|fq)(\.gz)?$'),
169+
]
170+
171+
for sample_name, file_path in fastq_files.items():
172+
file_str = str(file_path)
173+
is_paired = False
174+
175+
for pattern1, pattern2 in paired_patterns:
176+
if re.search(pattern1, file_str):
177+
# Find corresponding R2/2 file
178+
potential_pair = re.sub(pattern1, pattern2, file_str)
179+
if Path(potential_pair).exists():
180+
paired_files[sample_name] = {
181+
'R1': file_path,
182+
'R2': Path(potential_pair)
183+
}
184+
is_paired = True
185+
break
186+
elif re.search(pattern2, file_str):
187+
# Find corresponding R1/1 file
188+
potential_pair = re.sub(pattern2, pattern1, file_str)
189+
if Path(potential_pair).exists():
190+
paired_files[sample_name] = {
191+
'R1': Path(potential_pair),
192+
'R2': file_path
193+
}
194+
is_paired = True
195+
break
196+
197+
if not is_paired:
198+
single_files[sample_name] = file_path
199+
200+
return {'paired': paired_files, 'single': single_files}

0 commit comments

Comments
 (0)