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 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
@@ -0,0 +1,10 @@
1
+ """Enable ``python -m adaptiseq`` as an alias for the ``adaptiseq`` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
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()