gpse 0.0.2__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.
- gpse/__init__.py +11 -0
- gpse/batch/__init__.py +3 -0
- gpse/batch/cli.py +72 -0
- gpse/batch/runner.py +206 -0
- gpse/cli.py +215 -0
- gpse/config/__init__.py +15 -0
- gpse/config/_topsis_config.py +205 -0
- gpse/config/constants.py +127 -0
- gpse/config/default.yaml +15 -0
- gpse/config/software.yaml +20 -0
- gpse/config/topsis.yaml +18 -0
- gpse/convert/__init__.py +11 -0
- gpse/convert/cli.py +65 -0
- gpse/convert/external.py +400 -0
- gpse/convert/genotype_matrix.py +349 -0
- gpse/convert/phenotype.py +409 -0
- gpse/convert/processor.py +815 -0
- gpse/convert/qc.py +424 -0
- gpse/convert/validators.py +181 -0
- gpse/convert/workflow.py +214 -0
- gpse/models/__init__.py +30 -0
- gpse/models/classification_model_optimizer.py +393 -0
- gpse/models/classification_models.py +8 -0
- gpse/models/model_optimizers.py +14 -0
- gpse/models/regression_model_optimizer.py +679 -0
- gpse/predict/__init__.py +5 -0
- gpse/predict/__main__.py +3 -0
- gpse/predict/cli.py +95 -0
- gpse/predict/core.py +315 -0
- gpse/tasks/__init__.py +12 -0
- gpse/tasks/classification.py +565 -0
- gpse/tools/__init__.py +1 -0
- gpse/tools/analyze_phenotypes.py +140 -0
- gpse/train/__init__.py +26 -0
- gpse/train/_cv_manager.py +234 -0
- gpse/train/_data_io.py +261 -0
- gpse/train/_ensemble.py +235 -0
- gpse/train/_feature_selection.py +287 -0
- gpse/train/_fold_training.py +304 -0
- gpse/train/_model_pipeline.py +125 -0
- gpse/train/_model_tools.py +97 -0
- gpse/train/_optimization.py +244 -0
- gpse/train/_pipeline.py +490 -0
- gpse/train/_repeat_training.py +665 -0
- gpse/train/_results.py +262 -0
- gpse/train/cli.py +408 -0
- gpse/train/predictor.py +305 -0
- gpse/train/stacking.py +230 -0
- gpse/train/topsis.py +214 -0
- gpse/train/workflow.py +416 -0
- gpse/utils/__init__.py +54 -0
- gpse/utils/cli_display.py +529 -0
- gpse/utils/configuration.py +192 -0
- gpse/utils/dependency_checker.py +280 -0
- gpse/utils/feature_manifest.py +63 -0
- gpse/utils/genomic_utils.py +812 -0
- gpse/utils/log_utils.py +569 -0
- gpse/utils/logo.py +235 -0
- gpse/utils/paralle.py +266 -0
- gpse/utils/print_utils.py +75 -0
- gpse/utils/snp_ids.py +61 -0
- gpse/utils/version.py +159 -0
- gpse-0.0.2.dist-info/METADATA +1123 -0
- gpse-0.0.2.dist-info/RECORD +67 -0
- gpse-0.0.2.dist-info/WHEEL +4 -0
- gpse-0.0.2.dist-info/entry_points.txt +3 -0
- gpse-0.0.2.dist-info/licenses/LICENSE +21 -0
gpse/convert/external.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Shared helpers for resolving and invoking external tools in GPSE convert.
|
|
5
|
+
|
|
6
|
+
Provides configuration-driven discovery of third-party executables (PLINK,
|
|
7
|
+
Java, Beagle, etc.), version checks, and safe command execution.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
import subprocess
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional, Sequence
|
|
18
|
+
|
|
19
|
+
from gpse.utils.configuration import load_software_config
|
|
20
|
+
from gpse.utils.dependency_checker import check_all_external_tools, check_external_tool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ensure_log_dir(output_prefix: str) -> str:
|
|
24
|
+
"""Return a dedicated ``log/`` subdirectory next to the output prefix.
|
|
25
|
+
|
|
26
|
+
Creates the directory if it does not exist.
|
|
27
|
+
"""
|
|
28
|
+
base_dir = os.path.dirname(output_prefix) or "."
|
|
29
|
+
log_dir = os.path.join(base_dir, "log")
|
|
30
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
31
|
+
return log_dir
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_external_tools(
|
|
35
|
+
config_path: Optional[str] = None,
|
|
36
|
+
*,
|
|
37
|
+
auto_project_config: bool = False,
|
|
38
|
+
) -> list[dict]:
|
|
39
|
+
"""Load external tool definitions from gpse/config/software.yaml."""
|
|
40
|
+
return load_software_config(
|
|
41
|
+
config_path,
|
|
42
|
+
auto_project_config=auto_project_config,
|
|
43
|
+
).get("external_tools", [])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_external_tool_config(
|
|
47
|
+
name: str,
|
|
48
|
+
*,
|
|
49
|
+
config_path: Optional[str] = None,
|
|
50
|
+
auto_project_config: bool = False,
|
|
51
|
+
) -> dict:
|
|
52
|
+
"""Return a configured external tool by name, or a minimal fallback."""
|
|
53
|
+
for tool in _load_external_tools(
|
|
54
|
+
config_path,
|
|
55
|
+
auto_project_config=auto_project_config,
|
|
56
|
+
):
|
|
57
|
+
if tool.get("name") == name:
|
|
58
|
+
return dict(tool)
|
|
59
|
+
return {"name": name, "cmd": name, "version_flag": "--version", "required": True}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_configured_external_tools(
|
|
63
|
+
command_overrides: Optional[dict[str, str]] = None,
|
|
64
|
+
*,
|
|
65
|
+
config_path: Optional[str] = None,
|
|
66
|
+
auto_project_config: bool = False,
|
|
67
|
+
) -> list[dict]:
|
|
68
|
+
"""Check every external tool defined in software.yaml."""
|
|
69
|
+
command_overrides = command_overrides or {}
|
|
70
|
+
tools = []
|
|
71
|
+
for tool in _load_external_tools(
|
|
72
|
+
config_path,
|
|
73
|
+
auto_project_config=auto_project_config,
|
|
74
|
+
):
|
|
75
|
+
configured = dict(tool)
|
|
76
|
+
override = command_overrides.get(configured.get("name"))
|
|
77
|
+
if override:
|
|
78
|
+
configured["cmd"] = override
|
|
79
|
+
tools.append(configured)
|
|
80
|
+
return check_all_external_tools(tools)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_convert_config(
|
|
84
|
+
config_path: Optional[str] = None,
|
|
85
|
+
*,
|
|
86
|
+
auto_project_config: bool = False,
|
|
87
|
+
) -> dict:
|
|
88
|
+
"""Load the optional 'convert' section from merged GPSE YAML config."""
|
|
89
|
+
convert_config = load_software_config(
|
|
90
|
+
config_path,
|
|
91
|
+
auto_project_config=auto_project_config,
|
|
92
|
+
).get("convert") or {}
|
|
93
|
+
if not isinstance(convert_config, dict):
|
|
94
|
+
raise TypeError("'convert' config section must be a mapping")
|
|
95
|
+
return convert_config
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def resolve_external_tool(
|
|
99
|
+
tool_path: str,
|
|
100
|
+
*,
|
|
101
|
+
name: Optional[str] = None,
|
|
102
|
+
version_flag: str = "--version",
|
|
103
|
+
min_version: Optional[str] = None,
|
|
104
|
+
) -> str:
|
|
105
|
+
"""Resolve an executable path and optionally validate its version."""
|
|
106
|
+
if not tool_path:
|
|
107
|
+
raise FileNotFoundError("External tool path is empty")
|
|
108
|
+
|
|
109
|
+
result = check_external_tool(
|
|
110
|
+
name=name or Path(tool_path).name,
|
|
111
|
+
cmd=tool_path,
|
|
112
|
+
version_flag=version_flag,
|
|
113
|
+
min_version=min_version,
|
|
114
|
+
)
|
|
115
|
+
if not result["available"]:
|
|
116
|
+
raise FileNotFoundError(f"External tool not found: {tool_path}")
|
|
117
|
+
if not result.get("version_ok", True):
|
|
118
|
+
raise RuntimeError(
|
|
119
|
+
f"External tool version too low: {result['name']} "
|
|
120
|
+
f"{result.get('version')} < required {result.get('min_version')}"
|
|
121
|
+
)
|
|
122
|
+
return result["path"] or tool_path
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_configured_tool(
|
|
126
|
+
name: str,
|
|
127
|
+
command_override: Optional[str] = None,
|
|
128
|
+
*,
|
|
129
|
+
config_path: Optional[str] = None,
|
|
130
|
+
auto_project_config: bool = False,
|
|
131
|
+
) -> str:
|
|
132
|
+
"""Resolve a tool using software.yaml defaults, with an optional command override."""
|
|
133
|
+
tool = get_external_tool_config(
|
|
134
|
+
name,
|
|
135
|
+
config_path=config_path,
|
|
136
|
+
auto_project_config=auto_project_config,
|
|
137
|
+
)
|
|
138
|
+
return resolve_external_tool(
|
|
139
|
+
command_override or tool.get("cmd") or name,
|
|
140
|
+
name=tool.get("name", name),
|
|
141
|
+
version_flag=tool.get("version_flag", "--version"),
|
|
142
|
+
min_version=tool.get("min_version"),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def ensure_existing_file(file_path: str, *, name: str = "file") -> str:
|
|
147
|
+
"""Return *file_path* if it points to an existing regular file."""
|
|
148
|
+
if not file_path:
|
|
149
|
+
raise FileNotFoundError(f"{name} path is empty")
|
|
150
|
+
path = Path(file_path)
|
|
151
|
+
if not path.is_file():
|
|
152
|
+
raise FileNotFoundError(f"{name} not found: {file_path}")
|
|
153
|
+
return str(path)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _compress_stdout(lines: list[str]) -> list[str]:
|
|
157
|
+
"""Compress high-frequency progress spam from external tools (e.g. PLINK).
|
|
158
|
+
|
|
159
|
+
Rules:
|
|
160
|
+
1. ``--vcf: Nk variants complete.`` — keep every 10 k plus the first.
|
|
161
|
+
2. Glued-together VCF progress lines — drop entirely.
|
|
162
|
+
3. Lines with many percentages + "done" — clean and keep prefix.
|
|
163
|
+
4. Standalone ``N%`` fragments — drop entirely.
|
|
164
|
+
5. Lines with many inline percentages — strip the percentage run, keep the prefix.
|
|
165
|
+
"""
|
|
166
|
+
out: list[str] = []
|
|
167
|
+
vcf_seen = 0
|
|
168
|
+
|
|
169
|
+
for raw in lines:
|
|
170
|
+
line = raw.strip()
|
|
171
|
+
if not line:
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
# 1. VCF milestone filter
|
|
175
|
+
m = re.match(r"^--vcf:\s+(\d+)k variants complete\.$", line)
|
|
176
|
+
if m:
|
|
177
|
+
vcf_seen += 1
|
|
178
|
+
n = int(m.group(1))
|
|
179
|
+
if n == 1 or n % 10 == 0:
|
|
180
|
+
out.append(line)
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
# 2. Drop glued-together VCF progress lines (multiple --vcf: messages in one line)
|
|
184
|
+
if "--vcf:" in line:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
# 3. Strip inline progress-bar completion with "done" (with or without space, with or without dot)
|
|
188
|
+
if re.search(r"(?:\d+%){3,}.*done\.?$", line):
|
|
189
|
+
cleaned = re.sub(r"\s*(?:\d+%){3,}.*done\.?$", "", line).strip()
|
|
190
|
+
if cleaned:
|
|
191
|
+
out.append(cleaned)
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# 4. Drop \r-split progress fragments (e.g. standalone "1%", "2%" ...).
|
|
195
|
+
if re.fullmatch(r"\d+%", line):
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
# 5. Strip many inline percentages (PLINK progress bars without "done").
|
|
199
|
+
# Keep the meaningful prefix.
|
|
200
|
+
if re.search(r"(?:\d+%){3,}", line):
|
|
201
|
+
cleaned = re.sub(r"\s*(?:\d+%){3,}.*", "", line).strip()
|
|
202
|
+
if cleaned:
|
|
203
|
+
out.append(cleaned)
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
out.append(line)
|
|
207
|
+
|
|
208
|
+
# If any VCF lines were filtered, append a summary so the user still knows
|
|
209
|
+
# the total scale of the job.
|
|
210
|
+
if vcf_seen:
|
|
211
|
+
kept = sum(1 for l in out if l.startswith("--vcf:"))
|
|
212
|
+
if kept < vcf_seen:
|
|
213
|
+
out.append(f"(VCF progress: {vcf_seen} milestones total, {kept} shown)")
|
|
214
|
+
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _is_plink_cmd(cmd_args: list[str]) -> bool:
|
|
219
|
+
"""Return True if the command looks like a PLINK invocation."""
|
|
220
|
+
if not cmd_args:
|
|
221
|
+
return False
|
|
222
|
+
return "plink" in os.path.basename(cmd_args[0]).lower()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _extract_plink_out_prefix(cmd_args: list[str]) -> str | None:
|
|
226
|
+
"""Extract the --out prefix from a PLINK command line."""
|
|
227
|
+
try:
|
|
228
|
+
out_idx = cmd_args.index("--out")
|
|
229
|
+
return cmd_args[out_idx + 1]
|
|
230
|
+
except (ValueError, IndexError):
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _log_bfile_stats(prefix: str, label: str, logger) -> None:
|
|
235
|
+
"""Log variant and sample counts for a PLINK binary prefix.
|
|
236
|
+
|
|
237
|
+
Numbers are highlighted with ANSI bold-cyan so the Rich sink renders
|
|
238
|
+
them prominently.
|
|
239
|
+
"""
|
|
240
|
+
if not prefix:
|
|
241
|
+
return
|
|
242
|
+
bim_path = prefix + ".bim"
|
|
243
|
+
fam_path = prefix + ".fam"
|
|
244
|
+
if not os.path.isfile(bim_path) or not os.path.isfile(fam_path):
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
with open(bim_path, "r") as f:
|
|
249
|
+
n_variants = sum(1 for _ in f)
|
|
250
|
+
with open(fam_path, "r") as f:
|
|
251
|
+
n_samples = sum(1 for _ in f)
|
|
252
|
+
except OSError:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
cyan = "\033[1;36m"
|
|
256
|
+
reset = "\033[0m"
|
|
257
|
+
logger.info(
|
|
258
|
+
f"{label} — Variants: {cyan}{n_variants:,}{reset}, "
|
|
259
|
+
f"Samples: {cyan}{n_samples:,}{reset}"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _plink_chr_error_hint(cmd_args: list[str], logger) -> None:
|
|
264
|
+
"""When a PLINK command fails, check its log for chromosome-name errors
|
|
265
|
+
and suggest ``--allow-extra-chr`` if applicable."""
|
|
266
|
+
if not _is_plink_cmd(cmd_args) or "--allow-extra-chr" in cmd_args:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
# Look for PLINK's auto-generated log next to the --out prefix.
|
|
270
|
+
try:
|
|
271
|
+
out_idx = cmd_args.index("--out")
|
|
272
|
+
log_path = cmd_args[out_idx + 1] + ".log"
|
|
273
|
+
except (ValueError, IndexError):
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
if not os.path.isfile(log_path):
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
log_text = Path(log_path).read_text(encoding="utf-8", errors="replace")
|
|
281
|
+
except OSError:
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
# PLINK prints this exact phrase when it encounters non-standard chroms.
|
|
285
|
+
if "Allow-extra-chr" in log_text or "allow-extra-chr" in log_text:
|
|
286
|
+
logger.error(
|
|
287
|
+
"PLINK rejected non-standard chromosome names in this VCF. "
|
|
288
|
+
"Re-run with --allow-extra-chr to accept them."
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _plink_qc_error_hint(exc: subprocess.CalledProcessError, logger) -> None:
|
|
293
|
+
"""Check if a PLINK command failed because all samples or SNPs were removed
|
|
294
|
+
by missingness filters (exit code 11 or 1)."""
|
|
295
|
+
if not _is_plink_cmd(exc.cmd):
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
# Exit code 11 often means "All people removed" or "All variants removed"
|
|
299
|
+
# during --mind/--geno filtering.
|
|
300
|
+
if exc.returncode in (1, 11):
|
|
301
|
+
try:
|
|
302
|
+
# Check for --mind or --geno in the command line
|
|
303
|
+
has_miss_filter = "--mind" in exc.cmd or "--geno" in exc.cmd
|
|
304
|
+
if has_miss_filter:
|
|
305
|
+
logger.error(
|
|
306
|
+
"QC FAILURE: All samples or variants were removed by PLINK filters. "
|
|
307
|
+
"This usually means the initial missingness rate is higher than "
|
|
308
|
+
"the thresholds specified by --snpmaxmiss or --samplemaxmiss. "
|
|
309
|
+
"TIP: Try relaxing the thresholds (e.g., set them to 0.5 or 1.0) "
|
|
310
|
+
"or enable --impute to fill missing data first."
|
|
311
|
+
)
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _beagle_qc_error_hint(exc: subprocess.CalledProcessError, logger) -> None:
|
|
317
|
+
"""Check if a Beagle command failed due to missing alleles or format issues."""
|
|
318
|
+
if not exc.cmd or "java" not in os.path.basename(str(exc.cmd[0])).lower() or "beagle" not in " ".join(str(c).lower() for c in exc.cmd):
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
if exc.returncode != 0:
|
|
322
|
+
logger.error(
|
|
323
|
+
"IMPUTATION FAILURE: Beagle imputation failed. "
|
|
324
|
+
"This can happen if the input data has too many missing variants, "
|
|
325
|
+
"invalid REF/ALT alleles (e.g., indels or structural variants not supported by Beagle), "
|
|
326
|
+
"or severe format issues. "
|
|
327
|
+
"TIP: Try running without --impute first, but relax your --snpmaxmiss and --samplemaxmiss "
|
|
328
|
+
"thresholds to see if the raw data can be processed without imputation."
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def run_command(
|
|
333
|
+
cmd_list: Sequence[object],
|
|
334
|
+
*,
|
|
335
|
+
log_file: Optional[str] = None,
|
|
336
|
+
logger=None,
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Run an external command without invoking a shell."""
|
|
339
|
+
cmd_args = [
|
|
340
|
+
os.fspath(arg) if isinstance(arg, (Path, os.PathLike)) else str(arg)
|
|
341
|
+
for arg in cmd_list
|
|
342
|
+
]
|
|
343
|
+
cmd_text = shlex.join(cmd_args)
|
|
344
|
+
if logger is not None:
|
|
345
|
+
logger.info(f"Executing: {cmd_text}")
|
|
346
|
+
|
|
347
|
+
if log_file:
|
|
348
|
+
try:
|
|
349
|
+
with open(log_file, "a") as f:
|
|
350
|
+
subprocess.run(cmd_args, stdout=f, stderr=f, check=True)
|
|
351
|
+
except subprocess.CalledProcessError as exc:
|
|
352
|
+
if logger is not None:
|
|
353
|
+
_plink_chr_error_hint(cmd_args, logger)
|
|
354
|
+
_plink_qc_error_hint(exc, logger)
|
|
355
|
+
_beagle_qc_error_hint(exc, logger)
|
|
356
|
+
raise
|
|
357
|
+
if logger is not None and _is_plink_cmd(cmd_args):
|
|
358
|
+
out_prefix = _extract_plink_out_prefix(cmd_args)
|
|
359
|
+
if out_prefix:
|
|
360
|
+
_log_bfile_stats(out_prefix, "PLINK", logger)
|
|
361
|
+
else:
|
|
362
|
+
# When a logger is available but no dedicated log file is requested,
|
|
363
|
+
# capture the subprocess output and stream it through the logger so
|
|
364
|
+
# that external tool messages (e.g. PLINK) are tidy and unified with
|
|
365
|
+
# GPSE's own log format instead of being dumped raw to the terminal.
|
|
366
|
+
if logger is not None:
|
|
367
|
+
try:
|
|
368
|
+
result = subprocess.run(
|
|
369
|
+
cmd_args, capture_output=True, check=True
|
|
370
|
+
)
|
|
371
|
+
except subprocess.CalledProcessError as exc:
|
|
372
|
+
# Print whatever output PLINK produced before the error.
|
|
373
|
+
if exc.stdout:
|
|
374
|
+
stdout_text = exc.stdout.decode("utf-8", errors="replace").replace("\r", "")
|
|
375
|
+
for line in _compress_stdout(re.split(r"[\r\n]+", stdout_text.strip())):
|
|
376
|
+
logger.info(f"[stdout] {line}")
|
|
377
|
+
if exc.stderr:
|
|
378
|
+
stderr_text = exc.stderr.decode("utf-8", errors="replace").replace("\r", "")
|
|
379
|
+
for line in _compress_stdout(re.split(r"[\r\n]+", stderr_text.strip())):
|
|
380
|
+
logger.warning(f"[stderr] {line}")
|
|
381
|
+
_plink_chr_error_hint(cmd_args, logger)
|
|
382
|
+
_plink_qc_error_hint(exc, logger)
|
|
383
|
+
_beagle_qc_error_hint(exc, logger)
|
|
384
|
+
raise
|
|
385
|
+
if result.stdout:
|
|
386
|
+
stdout_text = result.stdout.decode('utf-8', errors='replace').replace('\r', '')
|
|
387
|
+
raw_lines = re.split(r'[\r\n]+', stdout_text.strip())
|
|
388
|
+
for line in _compress_stdout(raw_lines):
|
|
389
|
+
logger.info(f"[stdout] {line}")
|
|
390
|
+
if result.stderr:
|
|
391
|
+
stderr_text = result.stderr.decode('utf-8', errors='replace').replace('\r', '')
|
|
392
|
+
raw_err_lines = re.split(r'[\r\n]+', stderr_text.strip())
|
|
393
|
+
for line in _compress_stdout(raw_err_lines):
|
|
394
|
+
logger.warning(f"[stderr] {line}")
|
|
395
|
+
if _is_plink_cmd(cmd_args):
|
|
396
|
+
out_prefix = _extract_plink_out_prefix(cmd_args)
|
|
397
|
+
if out_prefix:
|
|
398
|
+
_log_bfile_stats(out_prefix, "PLINK", logger)
|
|
399
|
+
else:
|
|
400
|
+
subprocess.run(cmd_args, check=True)
|