adaptiseq 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adaptiseq/__init__.py +244 -0
- adaptiseq/__main__.py +10 -0
- adaptiseq/_async.py +49 -0
- adaptiseq/accession.py +94 -0
- adaptiseq/aspera.py +272 -0
- adaptiseq/batch.py +556 -0
- adaptiseq/cli.py +396 -0
- adaptiseq/console.py +112 -0
- adaptiseq/convert.py +97 -0
- adaptiseq/core.py +402 -0
- adaptiseq/engine/__init__.py +21 -0
- adaptiseq/engine/classic.py +224 -0
- adaptiseq/engine/ftp.py +316 -0
- adaptiseq/engine/gate.py +48 -0
- adaptiseq/engine/optimize.py +137 -0
- adaptiseq/engine/ratelimit.py +177 -0
- adaptiseq/engine/seam.py +204 -0
- adaptiseq/engine/segmented.py +597 -0
- adaptiseq/engine/throughput.py +155 -0
- adaptiseq/errors.py +52 -0
- adaptiseq/integrity.py +260 -0
- adaptiseq/logs.py +61 -0
- adaptiseq/merge.py +314 -0
- adaptiseq/metadata.py +295 -0
- adaptiseq/net.py +105 -0
- adaptiseq/options.py +168 -0
- adaptiseq/preflight.py +75 -0
- adaptiseq/progress.py +81 -0
- adaptiseq/py.typed +0 -0
- adaptiseq/ratelimits.py +106 -0
- adaptiseq/resolve.py +395 -0
- adaptiseq/routing.py +71 -0
- adaptiseq-0.1.3.dist-info/METADATA +400 -0
- adaptiseq-0.1.3.dist-info/RECORD +38 -0
- adaptiseq-0.1.3.dist-info/WHEEL +5 -0
- adaptiseq-0.1.3.dist-info/entry_points.txt +2 -0
- adaptiseq-0.1.3.dist-info/licenses/LICENSE +21 -0
- adaptiseq-0.1.3.dist-info/top_level.txt +1 -0
adaptiseq/__init__.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""adaptiSeq — a tested, importable downloader for public sequencing data.
|
|
2
|
+
|
|
3
|
+
Part 1 provides the classic ``wget``/``axel`` engine. The segmented and adaptive
|
|
4
|
+
engines arrive in Parts 2 and 3.
|
|
5
|
+
|
|
6
|
+
Public library API (Section 6) — none of these print colour codes or call
|
|
7
|
+
``sys.exit``; they return values and raise the typed exceptions in
|
|
8
|
+
:mod:`adaptiseq.errors`::
|
|
9
|
+
|
|
10
|
+
from adaptiseq import fetch, resolve, get_metadata
|
|
11
|
+
|
|
12
|
+
records = get_metadata("SRR7706354") # parsed metadata rows
|
|
13
|
+
urls = resolve("SRR7706354", database="ena") # resolved download URLs
|
|
14
|
+
result = fetch("SRR7706354", outdir="data/", gzip=True) # download + verify
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import tempfile
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
from . import core
|
|
25
|
+
from . import metadata as _meta
|
|
26
|
+
from . import resolve as _resolve
|
|
27
|
+
from .accession import is_gsa
|
|
28
|
+
from .console import NullReporter, Reporter
|
|
29
|
+
from .engine import get_engine
|
|
30
|
+
from .errors import (
|
|
31
|
+
AdaptiSeqError,
|
|
32
|
+
DownloadError,
|
|
33
|
+
EngineUnavailableError,
|
|
34
|
+
IntegrityError,
|
|
35
|
+
InvalidAccessionError,
|
|
36
|
+
MergeError,
|
|
37
|
+
MetadataError,
|
|
38
|
+
PreflightError,
|
|
39
|
+
)
|
|
40
|
+
from .logs import FAIL_LOG, SUCCESS_LOG
|
|
41
|
+
from .options import Options, RunContext, load_accessions, resolve_output_dir
|
|
42
|
+
|
|
43
|
+
__version__ = "0.1.3"
|
|
44
|
+
__all__ = [
|
|
45
|
+
"fetch",
|
|
46
|
+
"resolve",
|
|
47
|
+
"get_metadata",
|
|
48
|
+
"FetchResult",
|
|
49
|
+
"Options",
|
|
50
|
+
"AdaptiSeqError",
|
|
51
|
+
"InvalidAccessionError",
|
|
52
|
+
"MetadataError",
|
|
53
|
+
"DownloadError",
|
|
54
|
+
"IntegrityError",
|
|
55
|
+
"MergeError",
|
|
56
|
+
"PreflightError",
|
|
57
|
+
"EngineUnavailableError",
|
|
58
|
+
"__version__",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class FetchResult:
|
|
64
|
+
"""Outcome of a :func:`fetch` call."""
|
|
65
|
+
|
|
66
|
+
accession: str
|
|
67
|
+
outdir: Path
|
|
68
|
+
failed: bool
|
|
69
|
+
success_ids: List[str] = field(default_factory=list)
|
|
70
|
+
fail_ids: List[str] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _log_ids(path: Path) -> List[str]:
|
|
74
|
+
if not path.exists():
|
|
75
|
+
return []
|
|
76
|
+
ids = []
|
|
77
|
+
for line in path.read_text(errors="replace").splitlines():
|
|
78
|
+
parts = line.split("\t")
|
|
79
|
+
if len(parts) >= 2 and parts[1]:
|
|
80
|
+
ids.append(parts[1])
|
|
81
|
+
return ids
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_metadata(
|
|
85
|
+
accession: str,
|
|
86
|
+
*,
|
|
87
|
+
database: str = "auto",
|
|
88
|
+
outdir: Optional[str] = None,
|
|
89
|
+
) -> List[Dict[str, str]]:
|
|
90
|
+
"""Fetch and parse metadata for ``accession``.
|
|
91
|
+
|
|
92
|
+
Returns a list of row dicts (TSV columns for ENA/SRA, CSV columns for GSA).
|
|
93
|
+
Writes the same ``.metadata.*`` files iseq writes; when ``outdir`` is omitted
|
|
94
|
+
a temporary directory is used and cleaned up. Raises :class:`MetadataError`
|
|
95
|
+
/ :class:`InvalidAccessionError` on failure.
|
|
96
|
+
"""
|
|
97
|
+
if outdir is not None:
|
|
98
|
+
workdir = resolve_output_dir(outdir)
|
|
99
|
+
return _get_metadata_in(accession, database, workdir)
|
|
100
|
+
with tempfile.TemporaryDirectory(prefix="adaptiseq-meta-") as tmp:
|
|
101
|
+
return _get_metadata_in(accession, database, Path(tmp))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _get_metadata_in(accession: str, database: str, workdir: Path) -> List[Dict]:
|
|
105
|
+
ctx = RunContext(
|
|
106
|
+
options=Options(database=database, metadata=True),
|
|
107
|
+
reporter=NullReporter(),
|
|
108
|
+
workdir=workdir,
|
|
109
|
+
)
|
|
110
|
+
if is_gsa(accession):
|
|
111
|
+
path = _meta.get_gsa_metadata(ctx, accession)
|
|
112
|
+
return _meta.parse_csv(path)
|
|
113
|
+
path = _meta.get_sra_metadata(ctx, accession)
|
|
114
|
+
return _meta.parse_tsv(path)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve(
|
|
118
|
+
accession: str,
|
|
119
|
+
*,
|
|
120
|
+
database: str = "auto",
|
|
121
|
+
gzip: bool = False,
|
|
122
|
+
fastq: bool = False,
|
|
123
|
+
aspera: bool = False,
|
|
124
|
+
protocol: str = "ftp",
|
|
125
|
+
outdir: Optional[str] = None,
|
|
126
|
+
) -> List[str]:
|
|
127
|
+
"""Resolve the download URL(s) for every Run under ``accession``.
|
|
128
|
+
|
|
129
|
+
Fetches metadata (same as iseq), then returns the URLs the classic engine
|
|
130
|
+
would fetch — without downloading. Raises the same typed exceptions as
|
|
131
|
+
:func:`get_metadata`.
|
|
132
|
+
"""
|
|
133
|
+
opts = Options(
|
|
134
|
+
database=database, gzip=gzip, fastq=fastq, aspera=aspera, protocol=protocol
|
|
135
|
+
)
|
|
136
|
+
if outdir is not None:
|
|
137
|
+
workdir = resolve_output_dir(outdir)
|
|
138
|
+
return _resolve_in(accession, opts, workdir)
|
|
139
|
+
with tempfile.TemporaryDirectory(prefix="adaptiseq-resolve-") as tmp:
|
|
140
|
+
return _resolve_in(accession, opts, Path(tmp))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _resolve_in(accession: str, opts: Options, workdir: Path) -> List[str]:
|
|
144
|
+
ctx = RunContext(options=opts, reporter=NullReporter(), workdir=workdir)
|
|
145
|
+
ctx.engine = get_engine(opts, workdir, ctx.reporter)
|
|
146
|
+
ctx.accession = accession
|
|
147
|
+
urls: List[str] = []
|
|
148
|
+
if is_gsa(accession):
|
|
149
|
+
csv = _meta.get_gsa_metadata(ctx, accession)
|
|
150
|
+
lines = csv.read_text(errors="replace").splitlines()
|
|
151
|
+
crrs = sorted({ln.split(",")[0] for ln in lines[1:] if ln.split(",")[0]})
|
|
152
|
+
for crr in crrs:
|
|
153
|
+
urls.extend(_resolve.resolve_gsa_urls(ctx, crr))
|
|
154
|
+
else:
|
|
155
|
+
tsv = _meta.get_sra_metadata(ctx, accession)
|
|
156
|
+
lines = tsv.read_text(errors="replace").splitlines()
|
|
157
|
+
for ln in lines[1:]:
|
|
158
|
+
run = ln.split("\t")[0]
|
|
159
|
+
if run:
|
|
160
|
+
urls.extend(_resolve.resolve_sra_urls(ctx, run))
|
|
161
|
+
return [u for u in urls if u]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def fetch(
|
|
165
|
+
accession: str,
|
|
166
|
+
*,
|
|
167
|
+
outdir: Optional[str] = None,
|
|
168
|
+
metadata: bool = False,
|
|
169
|
+
gzip: bool = False,
|
|
170
|
+
fastq: bool = False,
|
|
171
|
+
threads: int = 8,
|
|
172
|
+
merge: Optional[str] = None,
|
|
173
|
+
database: str = "auto",
|
|
174
|
+
parallel: int = 0,
|
|
175
|
+
aspera: bool = False,
|
|
176
|
+
speed: int = 1000,
|
|
177
|
+
skip_md5: bool = False,
|
|
178
|
+
protocol: str = "auto",
|
|
179
|
+
quiet: bool = True,
|
|
180
|
+
engine: str = "segmented",
|
|
181
|
+
segment_size_mb: int = 512,
|
|
182
|
+
max_segments: int = 8,
|
|
183
|
+
max_conns_per_host: int = 0, # 0 = auto (jobs * max_segments)
|
|
184
|
+
jobs: int = 20,
|
|
185
|
+
adaptive: bool = True,
|
|
186
|
+
probe_window: int = 5,
|
|
187
|
+
cc_penalty: float = 1.01,
|
|
188
|
+
meta_jobs: int = 3,
|
|
189
|
+
aspera_efficiency: float = 0.70,
|
|
190
|
+
reporter: Optional[Reporter] = None,
|
|
191
|
+
) -> FetchResult:
|
|
192
|
+
"""Download and verify ``accession``.
|
|
193
|
+
|
|
194
|
+
``accession`` may be a single accession or a path to a file listing one
|
|
195
|
+
accession per line (the same ``-i`` semantics as the CLI), in which case the
|
|
196
|
+
whole list is downloaded — through the adaptive batch pool when the segmented
|
|
197
|
+
engine is active.
|
|
198
|
+
|
|
199
|
+
The default engine is ``segmented`` (resumable HTTP(S)/FTP); pass
|
|
200
|
+
``engine='classic'`` for the ``wget``/``axel`` path. Thin wrapper over
|
|
201
|
+
:func:`adaptiseq.core.run`. Returns a :class:`FetchResult` summarising
|
|
202
|
+
success/fail IDs read back from ``success.log``/``fail.log``. Does not print
|
|
203
|
+
colour or exit; pass a :class:`Reporter` to capture progress.
|
|
204
|
+
"""
|
|
205
|
+
if engine not in ("classic", "segmented"):
|
|
206
|
+
raise EngineUnavailableError(f"Unknown engine: {engine}")
|
|
207
|
+
|
|
208
|
+
options = Options(
|
|
209
|
+
metadata=metadata,
|
|
210
|
+
gzip=gzip,
|
|
211
|
+
fastq=fastq,
|
|
212
|
+
threads=threads,
|
|
213
|
+
merge=merge,
|
|
214
|
+
database=database,
|
|
215
|
+
parallel=parallel,
|
|
216
|
+
aspera=aspera,
|
|
217
|
+
speed=speed,
|
|
218
|
+
skip_md5=skip_md5,
|
|
219
|
+
protocol=protocol,
|
|
220
|
+
quiet=quiet,
|
|
221
|
+
output=outdir,
|
|
222
|
+
engine=engine,
|
|
223
|
+
segment_size=segment_size_mb * 1024 * 1024,
|
|
224
|
+
max_segments=max_segments,
|
|
225
|
+
max_conns_per_host=max_conns_per_host,
|
|
226
|
+
jobs=jobs,
|
|
227
|
+
adaptive=adaptive,
|
|
228
|
+
probe_window=probe_window,
|
|
229
|
+
cc_penalty=cc_penalty,
|
|
230
|
+
meta_jobs=meta_jobs,
|
|
231
|
+
aspera_efficiency=aspera_efficiency,
|
|
232
|
+
)
|
|
233
|
+
workdir = resolve_output_dir(outdir)
|
|
234
|
+
accessions = load_accessions(accession)
|
|
235
|
+
ctx = core.run(
|
|
236
|
+
accessions, options, reporter=reporter or NullReporter(), workdir=workdir
|
|
237
|
+
)
|
|
238
|
+
return FetchResult(
|
|
239
|
+
accession=accession,
|
|
240
|
+
outdir=workdir,
|
|
241
|
+
failed=ctx.failed,
|
|
242
|
+
success_ids=_log_ids(workdir / SUCCESS_LOG),
|
|
243
|
+
fail_ids=_log_ids(workdir / FAIL_LOG),
|
|
244
|
+
)
|
adaptiseq/__main__.py
ADDED
adaptiseq/_async.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Run an ``asyncio`` coroutine to completion from synchronous code.
|
|
2
|
+
|
|
3
|
+
``asyncio.run`` raises ``RuntimeError: asyncio.run() cannot be called from a
|
|
4
|
+
running event loop`` when a loop is already running in the current thread — the
|
|
5
|
+
case inside Jupyter / Google Colab / IPython and any other async host. The public
|
|
6
|
+
API (:func:`adaptiseq.fetch`) is documented as usable from "a script, notebook,
|
|
7
|
+
or pipeline", so it must work there.
|
|
8
|
+
|
|
9
|
+
:func:`run_sync` covers three situations:
|
|
10
|
+
|
|
11
|
+
1. **No loop running** — the normal path: :func:`asyncio.run`.
|
|
12
|
+
2. **A loop is running and has been made re-entrant by ``nest_asyncio``** — drive
|
|
13
|
+
the coroutine on that loop directly (spawning a worker thread instead would
|
|
14
|
+
deadlock, because ``nest_asyncio`` patches *all* loops to re-enter the main
|
|
15
|
+
one).
|
|
16
|
+
3. **A plain running loop (e.g. Jupyter without ``nest_asyncio``)** — run the
|
|
17
|
+
coroutine in a dedicated worker thread that owns its own event loop, so every
|
|
18
|
+
aiohttp session/connector is created and used on that one loop.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
25
|
+
from typing import Any, Coroutine, TypeVar
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_sync(coro: "Coroutine[Any, Any, T]") -> T:
|
|
31
|
+
"""Block until ``coro`` finishes and return its result, loop or no loop."""
|
|
32
|
+
try:
|
|
33
|
+
loop = asyncio.get_running_loop()
|
|
34
|
+
except RuntimeError:
|
|
35
|
+
# No event loop running in this thread: the normal, fast path.
|
|
36
|
+
return asyncio.run(coro)
|
|
37
|
+
|
|
38
|
+
# A loop is already running in this thread.
|
|
39
|
+
if getattr(loop, "_nest_patched", False):
|
|
40
|
+
# nest_asyncio has made this loop re-entrant — running the coroutine in a
|
|
41
|
+
# separate thread would deadlock (its "fresh" loop is patched to re-enter
|
|
42
|
+
# this one), so drive it directly on the running loop.
|
|
43
|
+
return loop.run_until_complete(coro)
|
|
44
|
+
|
|
45
|
+
# Plain running loop (notebook / async host without nest_asyncio): run the
|
|
46
|
+
# coroutine in a worker thread with its own loop and wait for it. The
|
|
47
|
+
# coroutine has not been awaited yet, so it is safe to hand to another thread.
|
|
48
|
+
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
49
|
+
return pool.submit(lambda: asyncio.run(coro)).result()
|
adaptiseq/accession.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Accession validation and GEO resolution (``validateQuery``).
|
|
2
|
+
|
|
3
|
+
The regexes are a behavioural contract: they define exactly which accession forms
|
|
4
|
+
are accepted. Extended-regex ``=~`` patterns map directly to Python ``re`` with the
|
|
5
|
+
same anchors and quantifiers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from .console import green, red_bold, Reporter, NullReporter
|
|
14
|
+
from .errors import InvalidAccessionError
|
|
15
|
+
from .net import wget_capture
|
|
16
|
+
|
|
17
|
+
# --- Accession guard patterns (=~ style) ----------------------------------------
|
|
18
|
+
# validateQuery (SRA/ENA/DDBJ/GEO path):
|
|
19
|
+
RE_PROJECT_STUDY = re.compile(r"^PRJ[EDN][A-Z][0-9]+$|^[EDS]RP[0-9]{6,}$")
|
|
20
|
+
RE_BIOSAMPLE_SAMPLE = re.compile(r"^SAM[EDN][A-Z]?[0-9]+$|^[EDS]RS[0-9]{6,}$")
|
|
21
|
+
RE_EXPERIMENT = re.compile(r"^[EDS]RX[0-9]{6,}$")
|
|
22
|
+
RE_RUN = re.compile(r"^[EDS]RR[0-9]{6,}$")
|
|
23
|
+
RE_GSE = re.compile(r"^GSE[0-9]+$")
|
|
24
|
+
RE_GSM = re.compile(r"^GSM[0-9]+$")
|
|
25
|
+
|
|
26
|
+
# GSA routing (main loop / getGSAMetadata):
|
|
27
|
+
RE_GSA = re.compile(r"^PRJC[A-Z][0-9]+$|^SAMC[0-9]+$|^CRA[0-9]+$|^CRX[0-9]+$|^CRR[0-9]+$")
|
|
28
|
+
RE_GSA_CRR_CRX = re.compile(r"^CRR[0-9]+$|^CRX[0-9]+$")
|
|
29
|
+
RE_GSA_PROJECT = re.compile(r"^PRJC[A-Z][0-9]+$|^SAMC[0-9]+$|^CRA[0-9]+$")
|
|
30
|
+
|
|
31
|
+
# grep -oe patterns used to scrape resolved IDs from HTML / metadata:
|
|
32
|
+
RE_BIOPROJECT_SCRAPE = re.compile(r"PRJ[EDN][A-Z][0-9]+")
|
|
33
|
+
RE_BIOSAMPLE_SCRAPE = re.compile(r"SAM[EDN][A-Z]?[0-9]+")
|
|
34
|
+
RE_CRA_SCRAPE = re.compile(r"CRA[0-9]+")
|
|
35
|
+
|
|
36
|
+
GEO_URL = "https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc={acc}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_gsa(accession: str) -> bool:
|
|
40
|
+
"""True if the accession routes to the GSA database (PRJC/SAMC/CRA/CRX/CRR)."""
|
|
41
|
+
return bool(RE_GSA.match(accession))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _uniq_adjacent(items: List[str]) -> List[str]:
|
|
45
|
+
"""Reproduce ``uniq`` (collapse *adjacent* duplicates, not a global sort)."""
|
|
46
|
+
out: List[str] = []
|
|
47
|
+
for it in items:
|
|
48
|
+
if not out or out[-1] != it:
|
|
49
|
+
out.append(it)
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_query(accession: str, reporter: Optional[Reporter] = None) -> str:
|
|
54
|
+
"""``validateQuery``: validate and normalise an accession.
|
|
55
|
+
|
|
56
|
+
Returns the query accession to feed the ENA/SRA metadata API. Direct
|
|
57
|
+
accession types pass through unchanged; GEO ``GSE``/``GSM`` are resolved to a
|
|
58
|
+
BioProject / BioSample via the NCBI GEO page. Raises
|
|
59
|
+
:class:`InvalidAccessionError` for anything unrecognised.
|
|
60
|
+
"""
|
|
61
|
+
reporter = reporter or NullReporter()
|
|
62
|
+
|
|
63
|
+
if RE_PROJECT_STUDY.match(accession):
|
|
64
|
+
return accession
|
|
65
|
+
if RE_BIOSAMPLE_SAMPLE.match(accession):
|
|
66
|
+
return accession
|
|
67
|
+
if RE_EXPERIMENT.match(accession):
|
|
68
|
+
return accession
|
|
69
|
+
if RE_RUN.match(accession):
|
|
70
|
+
return accession
|
|
71
|
+
if RE_GSE.match(accession):
|
|
72
|
+
html = wget_capture(GEO_URL.format(acc=accession))
|
|
73
|
+
matches = _uniq_adjacent(RE_BIOPROJECT_SCRAPE.findall(html))
|
|
74
|
+
if not matches:
|
|
75
|
+
raise InvalidAccessionError(
|
|
76
|
+
f"{accession} is not valid GEO Series accession."
|
|
77
|
+
)
|
|
78
|
+
bioproject = "\n".join(matches)
|
|
79
|
+
reporter.error(f"{green('Note')}: {accession} belongs to {bioproject}")
|
|
80
|
+
return bioproject
|
|
81
|
+
if RE_GSM.match(accession):
|
|
82
|
+
html = wget_capture(GEO_URL.format(acc=accession))
|
|
83
|
+
matches = _uniq_adjacent(RE_BIOSAMPLE_SCRAPE.findall(html))
|
|
84
|
+
if not matches:
|
|
85
|
+
raise InvalidAccessionError(
|
|
86
|
+
f"{accession} is not a valid GEO Sample accession."
|
|
87
|
+
)
|
|
88
|
+
biosample = "\n".join(matches)
|
|
89
|
+
reporter.error(f"{green('Note')}: {accession} belongs to {biosample}")
|
|
90
|
+
return biosample
|
|
91
|
+
|
|
92
|
+
raise InvalidAccessionError(
|
|
93
|
+
f"{accession} is not a valid Study, Sample, Experiment, or Run accession."
|
|
94
|
+
)
|
adaptiseq/aspera.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Adaptive parallel Aspera (``ascp``) download.
|
|
2
|
+
|
|
3
|
+
``ascp`` transfers cannot be paused/resumed mid-file, so the gradient controller
|
|
4
|
+
(which pauses + re-queues) does not apply. Aspera concurrency is controlled only
|
|
5
|
+
at **file-pickup boundaries** (start / don't-start a new ``ascp``), and tuned by an
|
|
6
|
+
**additive-increase + efficiency-hysteresis** controller (user spec, Part 5 item
|
|
7
|
+
3):
|
|
8
|
+
|
|
9
|
+
* establish a per-worker baseline throughput at one worker;
|
|
10
|
+
* each interval, tentatively add one worker and measure aggregate throughput;
|
|
11
|
+
* if it reaches at least ``--aspera-efficiency`` (default 0.70) of the *theoretical*
|
|
12
|
+
``workers × baseline``, keep the worker and try one more; otherwise drop that
|
|
13
|
+
worker and hold (hysteresis — stop adding, don't flap).
|
|
14
|
+
|
|
15
|
+
Throughput is measured by a :class:`DirGrowthMeter` (ascp's bytes are written
|
|
16
|
+
out-of-process). The download itself is delegated to a ``download_fn(task) -> bool``
|
|
17
|
+
so tests can drive the whole pool with a fake ``ascp`` while production passes
|
|
18
|
+
:meth:`ClassicEngine.fetch_aspera`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import logging
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Callable, List, Optional, Set, Tuple
|
|
27
|
+
|
|
28
|
+
from .batch import DownloadTask
|
|
29
|
+
from .console import NullReporter, Reporter, green
|
|
30
|
+
from .engine.gate import WorkerGate
|
|
31
|
+
from .engine.throughput import DirGrowthMeter
|
|
32
|
+
from .logs import in_success
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger("adaptiseq.aspera")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ============================ pure controller logic ===========================
|
|
38
|
+
|
|
39
|
+
def hysteresis_search(
|
|
40
|
+
jobs: int,
|
|
41
|
+
measure: Callable[[int], float],
|
|
42
|
+
efficiency: float,
|
|
43
|
+
*,
|
|
44
|
+
max_steps: Optional[int] = None,
|
|
45
|
+
) -> Tuple[int, List[Tuple[int, float, float]]]:
|
|
46
|
+
"""Additive-increase with efficiency hysteresis (pure + testable).
|
|
47
|
+
|
|
48
|
+
``measure(w)`` returns aggregate throughput at ``w`` active workers. Returns
|
|
49
|
+
``(final_active, trajectory)`` where each trajectory entry is
|
|
50
|
+
``(workers, throughput, efficiency)``. ``efficiency`` is the keep/stop
|
|
51
|
+
threshold in [0, 1].
|
|
52
|
+
"""
|
|
53
|
+
jobs = max(1, int(jobs))
|
|
54
|
+
traj: List[Tuple[int, float, float]] = []
|
|
55
|
+
|
|
56
|
+
base_t = measure(1)
|
|
57
|
+
baseline = base_t if base_t > 0 else 0.0
|
|
58
|
+
traj.append((1, base_t, 1.0))
|
|
59
|
+
active = 1
|
|
60
|
+
steps = 0
|
|
61
|
+
|
|
62
|
+
while active < jobs:
|
|
63
|
+
steps += 1
|
|
64
|
+
cand = active + 1
|
|
65
|
+
t = measure(cand)
|
|
66
|
+
|
|
67
|
+
if baseline <= 0:
|
|
68
|
+
# Never got a usable baseline at 1 worker; adopt this observation as
|
|
69
|
+
# the per-worker baseline and accept the worker once.
|
|
70
|
+
baseline = (t / cand) if cand else 0.0
|
|
71
|
+
eff = 1.0 if t > 0 else 0.0
|
|
72
|
+
else:
|
|
73
|
+
theoretical = cand * baseline
|
|
74
|
+
eff = (t / theoretical) if theoretical > 0 else 0.0
|
|
75
|
+
|
|
76
|
+
traj.append((cand, t, round(eff, 3)))
|
|
77
|
+
|
|
78
|
+
if eff >= efficiency:
|
|
79
|
+
active = cand # worker justified — keep it, try one more
|
|
80
|
+
else:
|
|
81
|
+
break # diminishing returns — drop it, hold (hysteresis)
|
|
82
|
+
|
|
83
|
+
if max_steps is not None and steps >= max_steps:
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
return active, traj
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ============================ async controller ================================
|
|
90
|
+
|
|
91
|
+
class HysteresisController:
|
|
92
|
+
"""Drives ``gate.active`` for ascp via :func:`hysteresis_search` over a meter."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, gate: WorkerGate, meter, *, probe_window: int = 5,
|
|
95
|
+
efficiency: float = 0.70):
|
|
96
|
+
self.gate = gate
|
|
97
|
+
self.meter = meter
|
|
98
|
+
self.probe_window = max(2, int(probe_window))
|
|
99
|
+
self.efficiency = float(efficiency)
|
|
100
|
+
self.done = False
|
|
101
|
+
self.trajectory: List[Tuple[int, float, float]] = []
|
|
102
|
+
|
|
103
|
+
async def _measure(self, w: int) -> float:
|
|
104
|
+
self.gate.set_active(w)
|
|
105
|
+
await asyncio.sleep(1.0) # settle
|
|
106
|
+
await asyncio.sleep(self.probe_window - 1.0) # observe
|
|
107
|
+
if self.done:
|
|
108
|
+
return 0.0
|
|
109
|
+
return self.meter.recent_average(max(1, self.probe_window - 1))
|
|
110
|
+
|
|
111
|
+
async def run(self) -> None:
|
|
112
|
+
loop = asyncio.get_event_loop()
|
|
113
|
+
await asyncio.sleep(self.probe_window) # initial settle
|
|
114
|
+
|
|
115
|
+
def measure(w: int) -> float:
|
|
116
|
+
if self.done:
|
|
117
|
+
return 0.0
|
|
118
|
+
fut = asyncio.run_coroutine_threadsafe(self._measure(w), loop)
|
|
119
|
+
return fut.result()
|
|
120
|
+
|
|
121
|
+
active, traj = await loop.run_in_executor(
|
|
122
|
+
None, lambda: hysteresis_search(self.gate.jobs, measure, self.efficiency)
|
|
123
|
+
)
|
|
124
|
+
self.trajectory = traj
|
|
125
|
+
self.gate.set_active(active)
|
|
126
|
+
log.info("aspera controller settled at %d workers (efficiency>=%.2f)",
|
|
127
|
+
active, self.efficiency)
|
|
128
|
+
while not self.done: # hold until the queue drains
|
|
129
|
+
await asyncio.sleep(0.5)
|
|
130
|
+
|
|
131
|
+
def stop(self) -> None:
|
|
132
|
+
self.done = True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ============================ aspera batch pool ===============================
|
|
136
|
+
|
|
137
|
+
class AsperaBatchDownloader:
|
|
138
|
+
"""Parallel ascp pool with file-boundary gating + the hysteresis controller."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, download_fn: Callable[[DownloadTask], bool], options,
|
|
141
|
+
workdir: Path, reporter: Optional[Reporter] = None):
|
|
142
|
+
self.download_fn = download_fn
|
|
143
|
+
self.options = options
|
|
144
|
+
self.workdir = Path(workdir)
|
|
145
|
+
self.reporter = reporter or NullReporter()
|
|
146
|
+
self.jobs = max(1, int(options.jobs))
|
|
147
|
+
self.adaptive = bool(options.adaptive)
|
|
148
|
+
|
|
149
|
+
async def run(self, tasks: List[DownloadTask]) -> Set[str]:
|
|
150
|
+
if not tasks:
|
|
151
|
+
return set()
|
|
152
|
+
from .progress import ProgressBar
|
|
153
|
+
|
|
154
|
+
queue: "asyncio.Queue[DownloadTask]" = asyncio.Queue()
|
|
155
|
+
for t in tasks:
|
|
156
|
+
queue.put_nowait(t)
|
|
157
|
+
|
|
158
|
+
meter = DirGrowthMeter(self.workdir)
|
|
159
|
+
# -j is a ceiling, not a target: never size the pool above the work available.
|
|
160
|
+
worker_slots = min(self.jobs, len(tasks))
|
|
161
|
+
active0 = worker_slots if not self.adaptive else 1
|
|
162
|
+
gate = WorkerGate(worker_slots, active=active0)
|
|
163
|
+
failed: Set[str] = set()
|
|
164
|
+
progress = ProgressBar(
|
|
165
|
+
total=len(tasks),
|
|
166
|
+
enabled=(None if not self.options.quiet else False),
|
|
167
|
+
label="aspera",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
meter.start()
|
|
171
|
+
controller = None
|
|
172
|
+
ctrl_task = None
|
|
173
|
+
if self.adaptive:
|
|
174
|
+
controller = HysteresisController(
|
|
175
|
+
gate, meter,
|
|
176
|
+
probe_window=self.options.probe_window,
|
|
177
|
+
efficiency=self.options.aspera_efficiency,
|
|
178
|
+
)
|
|
179
|
+
ctrl_task = asyncio.ensure_future(controller.run())
|
|
180
|
+
repaint = asyncio.ensure_future(self._repaint(progress, meter, gate))
|
|
181
|
+
|
|
182
|
+
workers = [
|
|
183
|
+
asyncio.ensure_future(self._worker(i, queue, gate, failed, progress))
|
|
184
|
+
for i in range(worker_slots)
|
|
185
|
+
]
|
|
186
|
+
await queue.join()
|
|
187
|
+
for w in workers:
|
|
188
|
+
w.cancel()
|
|
189
|
+
await asyncio.gather(*workers, return_exceptions=True)
|
|
190
|
+
|
|
191
|
+
repaint.cancel()
|
|
192
|
+
await asyncio.gather(repaint, return_exceptions=True)
|
|
193
|
+
progress.draw(meter.last_sample(), self._visible_workers(progress, gate))
|
|
194
|
+
progress.finish()
|
|
195
|
+
if controller is not None:
|
|
196
|
+
controller.stop()
|
|
197
|
+
if ctrl_task is not None:
|
|
198
|
+
ctrl_task.cancel()
|
|
199
|
+
await asyncio.gather(ctrl_task, return_exceptions=True)
|
|
200
|
+
await meter.stop()
|
|
201
|
+
self._controller = controller
|
|
202
|
+
self._progress = progress
|
|
203
|
+
self._gate = gate
|
|
204
|
+
self._worker_slots = worker_slots
|
|
205
|
+
self._initial_active = active0
|
|
206
|
+
return failed
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _remaining_files(progress) -> int:
|
|
210
|
+
"""Files not yet accounted for (downloaded, skipped, or given up on)."""
|
|
211
|
+
return max(0, progress.total - progress.done)
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def _visible_workers(cls, progress, gate) -> int:
|
|
215
|
+
"""Worker count to display: never more than the files still outstanding."""
|
|
216
|
+
remaining = cls._remaining_files(progress)
|
|
217
|
+
if remaining == 0:
|
|
218
|
+
return 0
|
|
219
|
+
return min(gate.active, remaining)
|
|
220
|
+
|
|
221
|
+
@classmethod
|
|
222
|
+
def _cap_gate_to_remaining(cls, progress, gate) -> None:
|
|
223
|
+
"""Lower the gate as the tail drains, so idle slots stop being counted."""
|
|
224
|
+
remaining = cls._remaining_files(progress)
|
|
225
|
+
if remaining > 0 and gate.active > remaining:
|
|
226
|
+
gate.set_active(remaining)
|
|
227
|
+
|
|
228
|
+
async def _repaint(self, progress, meter, gate) -> None:
|
|
229
|
+
try:
|
|
230
|
+
while True:
|
|
231
|
+
self._cap_gate_to_remaining(progress, gate)
|
|
232
|
+
progress.draw(meter.last_sample(), self._visible_workers(progress, gate))
|
|
233
|
+
await asyncio.sleep(0.4)
|
|
234
|
+
except asyncio.CancelledError:
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
async def _worker(self, i, queue, gate, failed, progress) -> None:
|
|
238
|
+
token = gate.token(i)
|
|
239
|
+
loop = asyncio.get_event_loop()
|
|
240
|
+
while True:
|
|
241
|
+
if not token.should_continue():
|
|
242
|
+
await asyncio.sleep(0.15)
|
|
243
|
+
continue
|
|
244
|
+
try:
|
|
245
|
+
task = queue.get_nowait()
|
|
246
|
+
except asyncio.QueueEmpty:
|
|
247
|
+
await asyncio.sleep(0.1)
|
|
248
|
+
continue
|
|
249
|
+
try:
|
|
250
|
+
if in_success(self.workdir, Path(task.save_path).name):
|
|
251
|
+
progress.inc()
|
|
252
|
+
self._cap_gate_to_remaining(progress, gate)
|
|
253
|
+
continue
|
|
254
|
+
try:
|
|
255
|
+
# ascp is a blocking subprocess — run it off the event loop.
|
|
256
|
+
ok = await loop.run_in_executor(None, self.download_fn, task)
|
|
257
|
+
except asyncio.CancelledError:
|
|
258
|
+
raise
|
|
259
|
+
except Exception as e:
|
|
260
|
+
log.warning("aspera error for %s: %s", task.save_path, e)
|
|
261
|
+
ok = False
|
|
262
|
+
if ok:
|
|
263
|
+
progress.inc()
|
|
264
|
+
self._cap_gate_to_remaining(progress, gate)
|
|
265
|
+
else:
|
|
266
|
+
task.retries += 1
|
|
267
|
+
if task.retries < 3:
|
|
268
|
+
queue.put_nowait(task)
|
|
269
|
+
else:
|
|
270
|
+
failed.add(task.save_path)
|
|
271
|
+
finally:
|
|
272
|
+
queue.task_done()
|