monadomics 0.2.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.
monadomics/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.3"
monadomics/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
monadomics/backend.py ADDED
@@ -0,0 +1,391 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic R-backed omics analyses shared by CLI commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Any, Callable
14
+
15
+
16
+ ROOT = Path(__file__).resolve().parent
17
+ R_DIR = ROOT / "r"
18
+ OUTPUT_DIR = Path(os.getenv("OMICS_OUTPUT_DIR", "~/.workbuddy/workspace/omics")).expanduser()
19
+
20
+ R_PACKAGE_GROUPS: dict[str, list[str]] = {
21
+ "core": ["jsonlite", "ggplot2", "svglite"],
22
+ "deg": ["DESeq2", "edgeR", "limma"],
23
+ "enrich": ["clusterProfiler", "org.Hs.eg.db", "org.Mm.eg.db", "ReactomePA", "GSVA"],
24
+ "plot": ["edgeR", "pheatmap", "ggrepel", "ggvenn", "svglite"],
25
+ "survival": ["survival", "glmnet", "timeROC", "rms"],
26
+ }
27
+
28
+
29
+ COMMANDS: list[dict[str, Any]] = [
30
+ {
31
+ "name": "doctor",
32
+ "description": "Report the R runtime and which omics R packages are installed, with the exact command to install anything missing. Call this before the first analysis in a session.",
33
+ "inputSchema": {
34
+ "type": "object",
35
+ "properties": {
36
+ "group": {"type": "string", "enum": ["all", "core", "deg", "enrich", "plot", "survival"], "default": "all"},
37
+ },
38
+ },
39
+ },
40
+ {
41
+ "name": "deg",
42
+ "description": "Two-group differential expression with DESeq2, edgeR, or limma; other groups are excluded before fitting. DESeq2/edgeR require raw integer counts. Returns full and significant tables, actual sample selection and optional sourced gene annotation.",
43
+ "inputSchema": {
44
+ "type": "object",
45
+ "properties": {
46
+ "method": {"type": "string", "enum": ["deseq2", "edger", "limma"], "default": "deseq2"},
47
+ "matrix_type": {"type": "string", "enum": ["counts", "normalized"]},
48
+ "matrix_path": {"type": "string", "description": "Gene-by-sample matrix; gene IDs in column 1."},
49
+ "matrix": {"type": "array", "items": {"type": "object"}},
50
+ "coldata_path": {"type": "string", "description": "Sample metadata; sample IDs in column 1."},
51
+ "coldata": {"type": "array", "items": {"type": "object"}},
52
+ "group_column": {"type": "string"},
53
+ "treat": {"type": "string"},
54
+ "control": {"type": "string"},
55
+ "covariates": {"type": "array", "items": {"type": "string"}},
56
+ "log2fc": {"type": "number", "default": 1},
57
+ "padj": {"type": "number", "default": 0.05},
58
+ "padj_method": {"type": "string", "default": "BH"},
59
+ "voom": {"type": "boolean", "default": False},
60
+ "species": {"type": "string", "enum": ["human", "mouse"], "description": "Optional gene annotation; supply together with id_type."},
61
+ "id_type": {"type": "string", "enum": ["SYMBOL", "ENSEMBL", "ENTREZID"], "description": "Original matrix ID type; required with species for annotation."},
62
+ "output_name": {"type": "string"},
63
+ },
64
+ "required": ["matrix_type", "group_column", "treat", "control"],
65
+ },
66
+ },
67
+ {
68
+ "name": "enrich",
69
+ "description": "Functional enrichment: GO/KEGG/Reactome over-representation, GSEA on a ranked list, or GSVA/ssGSEA per-sample pathway scores. Species is required because gene identifiers differ between organisms.",
70
+ "inputSchema": {
71
+ "type": "object",
72
+ "properties": {
73
+ "method": {"type": "string", "enum": ["go", "kegg", "reactome", "gsea", "gsva", "ssgsea"], "default": "go"},
74
+ "species": {"type": "string", "enum": ["human", "mouse"]},
75
+ "id_type": {"type": "string", "enum": ["SYMBOL", "ENSEMBL", "ENTREZID"]},
76
+ "genes": {"type": "array", "items": {"type": "string"}},
77
+ "universe": {"type": "array", "items": {"type": "string"}},
78
+ "ontology": {"type": "string", "enum": ["BP", "CC", "MF", "ALL"], "default": "BP"},
79
+ "ranked_path": {"type": "string"},
80
+ "ranked": {"type": "array", "items": {"type": "object"}},
81
+ "gene_column": {"type": "string"},
82
+ "metric_column": {"type": "string", "default": "log2FoldChange"},
83
+ "matrix_path": {"type": "string"},
84
+ "matrix": {"type": "array", "items": {"type": "object"}},
85
+ "matrix_type": {"type": "string", "enum": ["counts", "normalized"]},
86
+ "gene_sets": {"type": "object"},
87
+ "pvalue": {"type": "number", "default": 0.05},
88
+ "padj": {"type": "number", "default": 0.05, "description": "GSEA only: BH adjusted-P cutoff, separate from raw pvalue."},
89
+ "qvalue": {"type": "number", "description": "ORA: default 0.2. GSEA: optional additional q-value cutoff."},
90
+ "seed": {"type": "integer", "default": 42, "description": "GSEA random seed."},
91
+ "top_n": {"type": "integer", "default": 10},
92
+ "output_name": {"type": "string"},
93
+ },
94
+ "required": ["species", "id_type"],
95
+ },
96
+ },
97
+ {
98
+ "name": "plot",
99
+ "description": "Publication figures for expression analyses: volcano, heatmap, Venn, or PCA. Produces 300 dpi PNG plus editable SVG, and for Venn the per-region gene membership table.",
100
+ "inputSchema": {
101
+ "type": "object",
102
+ "properties": {
103
+ "type": {"type": "string", "enum": ["volcano", "heatmap", "venn", "pca"]},
104
+ "deg_path": {"type": "string"},
105
+ "deg": {"type": "array", "items": {"type": "object"}},
106
+ "matrix_path": {"type": "string"},
107
+ "matrix": {"type": "array", "items": {"type": "object"}},
108
+ "matrix_type": {"type": "string", "enum": ["counts", "normalized"]},
109
+ "coldata_path": {"type": "string"},
110
+ "coldata": {"type": "array", "items": {"type": "object"}},
111
+ "sets": {"type": "object", "description": "venn: 2-4 named gene vectors."},
112
+ "genes": {"type": "array", "items": {"type": "string"}},
113
+ "gene_column": {"type": "string", "default": "gene"},
114
+ "annotation_columns": {"type": "array", "items": {"type": "string"}},
115
+ "colour_column": {"type": "string"},
116
+ "log2fc": {"type": "number", "default": 1},
117
+ "padj": {"type": "number", "default": 0.05},
118
+ "label_top": {"type": "integer", "default": 10},
119
+ "label_genes": {"type": "array", "items": {"type": "string"}},
120
+ "label_samples": {"type": "boolean", "default": False},
121
+ "scale_rows": {"type": "boolean", "default": True},
122
+ "show_rownames": {"type": "boolean"},
123
+ "cluster_columns": {"type": "boolean", "default": True},
124
+ "top_variable": {"type": "integer", "default": 2000},
125
+ "title": {"type": "string"},
126
+ "width": {"type": "number"},
127
+ "height": {"type": "number"},
128
+ "output_name": {"type": "string"},
129
+ },
130
+ "required": ["type"],
131
+ },
132
+ },
133
+ {
134
+ "name": "survival",
135
+ "description": "Prognostic modelling: LASSO-Cox variable selection with risk score, time-dependent ROC, nomogram, bootstrap calibration, and decision curve analysis. Reports events-per-variable and flags optimistic training-set performance.",
136
+ "inputSchema": {
137
+ "type": "object",
138
+ "properties": {
139
+ "method": {"type": "string", "enum": ["lasso_cox", "timeroc", "nomogram", "calibration", "dca"], "default": "lasso_cox"},
140
+ "data_path": {"type": "string"},
141
+ "data": {"type": "array", "items": {"type": "object"}},
142
+ "time": {"type": "string", "default": "time"},
143
+ "event": {"type": "string", "default": "event", "description": "0/1 coded; 1 = event occurred."},
144
+ "predictors": {"type": "array", "items": {"type": "string"}},
145
+ "id_column": {"type": "string", "description": "Sample identifier column; defaults to the first column."},
146
+ "risk_column": {"type": "string", "default": "risk_score"},
147
+ "alpha": {"type": "number", "default": 1},
148
+ "nfolds": {"type": "integer", "default": 10},
149
+ "lambda": {"type": "string", "enum": ["1se", "min"], "default": "1se"},
150
+ "seed": {"type": "integer", "default": 42},
151
+ "times": {"type": "array", "items": {"type": "number"}},
152
+ "thresholds": {"type": "array", "items": {"type": "number"}},
153
+ "bootstrap": {"type": "integer", "default": 200},
154
+ "groups": {"type": "integer", "description": "Requested number of calibration groups."},
155
+ "width": {"type": "number"},
156
+ "height": {"type": "number"},
157
+ "output_name": {"type": "string"},
158
+ },
159
+ "required": ["method"],
160
+ },
161
+ },
162
+ {
163
+ "name": "capabilities",
164
+ "description": "Return the omics capability menu and routing hints.",
165
+ "inputSchema": {
166
+ "type": "object",
167
+ "properties": {"context": {"type": "string", "default": "general"}},
168
+ },
169
+ },
170
+ ]
171
+
172
+
173
+
174
+ def _find_rscript() -> str:
175
+ configured = os.getenv("OMICS_RSCRIPT", "").strip()
176
+ if configured:
177
+ found = shutil.which(str(Path(configured).expanduser()))
178
+ if not found:
179
+ raise RuntimeError("OMICS_RSCRIPT does not point to an executable Rscript. Correct the path and retry.")
180
+ return found
181
+ found = shutil.which("Rscript")
182
+ if found:
183
+ return found
184
+ # The macOS CRAN build is not on PATH for GUI-launched processes.
185
+ for candidate in [
186
+ Path("/Library/Frameworks/R.framework/Resources/bin/Rscript"),
187
+ Path("/opt/homebrew/bin/Rscript"),
188
+ Path("/usr/local/bin/Rscript"),
189
+ ]:
190
+ if candidate.exists():
191
+ return str(candidate)
192
+ raise RuntimeError(
193
+ "Rscript was not found. Install R (macOS: brew install r), then reopen the client. "
194
+ "If R is installed somewhere unusual, set OMICS_RSCRIPT to its Rscript path."
195
+ )
196
+
197
+
198
+ def _run_r(script: str, payload: dict[str, Any], timeout: int = 900) -> dict[str, Any]:
199
+ rscript = _find_rscript()
200
+ script_path = R_DIR / script
201
+ if not script_path.exists():
202
+ raise RuntimeError(f"Missing R script: {script_path}")
203
+ environment = dict(os.environ)
204
+ environment["OMICS_OUTPUT_DIR"] = str(OUTPUT_DIR)
205
+ environment["OMICS_R_DIR"] = str(R_DIR)
206
+ with tempfile.TemporaryDirectory() as workdir:
207
+ args_path = Path(workdir) / "args.json"
208
+ out_path = Path(workdir) / "result.json"
209
+ args_path.write_text(json.dumps(payload, ensure_ascii=False, default=str), encoding="utf-8")
210
+ try:
211
+ process = subprocess.run(
212
+ [rscript, "--vanilla", str(script_path), str(args_path), str(out_path)],
213
+ capture_output=True,
214
+ text=True,
215
+ timeout=timeout,
216
+ cwd=workdir,
217
+ env=environment,
218
+ )
219
+ except subprocess.TimeoutExpired as exc:
220
+ raise RuntimeError(
221
+ f"R script '{script}' exceeded {timeout}s. Reduce the input size, or run this step directly in R."
222
+ ) from exc
223
+ # The R wrapper writes a JSON payload even for handled errors; prefer it over stderr noise.
224
+ if out_path.exists():
225
+ try:
226
+ result = json.loads(out_path.read_text(encoding="utf-8"))
227
+ except json.JSONDecodeError:
228
+ result = None
229
+ if isinstance(result, dict):
230
+ if result.get("error"):
231
+ raise RuntimeError(str(result["error"]))
232
+ if process.returncode == 0:
233
+ return result
234
+ stderr = (process.stderr or "").strip()
235
+ raise RuntimeError(f"R script '{script}' failed (exit {process.returncode}): {stderr[-1500:] or 'no output'}")
236
+
237
+
238
+ def _r_vector(values: list[str]) -> str:
239
+ """Build an R character vector literal.
240
+
241
+ Package names are passed inside the -e expression rather than after --args,
242
+ because `commandArgs(trailingOnly=TRUE)` includes the literal "--args" when R
243
+ is invoked with -e, which silently poisons any check over that vector.
244
+ """
245
+ safe = [name for name in values if re.fullmatch(r"[A-Za-z0-9._]+", name)]
246
+ return "c(" + ",".join(f'"{name}"' for name in safe) + ")"
247
+
248
+
249
+ def doctor(args: dict[str, Any]) -> dict[str, Any]:
250
+ group = str(args.get("group", "all") or "all")
251
+ if group == "all":
252
+ packages = sorted({name for names in R_PACKAGE_GROUPS.values() for name in names})
253
+ elif group in R_PACKAGE_GROUPS:
254
+ packages = sorted(set(R_PACKAGE_GROUPS["core"]) | set(R_PACKAGE_GROUPS[group]))
255
+ else:
256
+ raise ValueError(f"Unknown group: {group}. Use all, core, deg, enrich, plot, or survival.")
257
+
258
+ try:
259
+ rscript = _find_rscript()
260
+ except RuntimeError as exc:
261
+ return {
262
+ "r_available": False,
263
+ "message": str(exc),
264
+ "install_r": "https://cran.r-project.org/",
265
+ "groups": R_PACKAGE_GROUPS,
266
+ }
267
+
268
+ probe = (
269
+ "if (getRversion() < '4.2.0') stop('R 4.2 or newer is required'); "
270
+ f"pkgs <- {_r_vector(packages)}; "
271
+ "cat(R.version.string, '\\n'); "
272
+ "for (p in pkgs) cat(p, as.integer(requireNamespace(p, quietly=TRUE)), '\\n')"
273
+ )
274
+ process = subprocess.run(
275
+ [rscript, "--vanilla", "-e", probe],
276
+ capture_output=True,
277
+ text=True,
278
+ timeout=180,
279
+ check=False,
280
+ )
281
+ if process.returncode:
282
+ raise RuntimeError(f"R package check failed (exit {process.returncode}): {process.stderr.strip()[-1500:]}")
283
+ lines = [line.strip() for line in (process.stdout or "").splitlines() if line.strip()]
284
+ version = lines[0] if lines else "unknown"
285
+ installed: list[str] = []
286
+ missing: list[str] = []
287
+ for line in lines[1:]:
288
+ parts = line.rsplit(" ", 1)
289
+ if len(parts) != 2:
290
+ continue
291
+ (installed if parts[1] == "1" else missing).append(parts[0])
292
+
293
+ if set(installed + missing) != set(packages):
294
+ raise RuntimeError("R package check returned incomplete results; dependency readiness is unknown.")
295
+
296
+ return {
297
+ "r_available": True,
298
+ "rscript": rscript,
299
+ "r_version": version,
300
+ "group": group,
301
+ "installed": installed,
302
+ "missing": missing,
303
+ "ready": not missing,
304
+ "install_command": None if not missing else f"monadomics setup-r {group}",
305
+ "output_dir": str(OUTPUT_DIR),
306
+ }
307
+
308
+
309
+ def deg(args: dict[str, Any]) -> dict[str, Any]:
310
+ return _run_r("deg.R", args, timeout=int(args.get("timeout", 900)))
311
+
312
+
313
+ def enrich(args: dict[str, Any]) -> dict[str, Any]:
314
+ return _run_r("enrich.R", args, timeout=int(args.get("timeout", 900)))
315
+
316
+
317
+ def plot(args: dict[str, Any]) -> dict[str, Any]:
318
+ return _run_r("plots.R", args, timeout=int(args.get("timeout", 600)))
319
+
320
+
321
+ def survival(args: dict[str, Any]) -> dict[str, Any]:
322
+ return _run_r("survival.R", args, timeout=int(args.get("timeout", 900)))
323
+
324
+
325
+ def feature_menu(args: dict[str, Any]) -> dict[str, Any]:
326
+ groups = [
327
+ {
328
+ "module": "数据准备与质控",
329
+ "items": [
330
+ "1. 表达矩阵与样本表核对",
331
+ "2. 数据类型判断(count / TPM / 芯片 / 已 log2)",
332
+ "3. 样本 PCA 与批次结构检查",
333
+ ],
334
+ },
335
+ {
336
+ "module": "差异表达",
337
+ "items": [
338
+ "4. DESeq2 差异分析(原始 count)",
339
+ "5. edgeR 差异分析(原始 count)",
340
+ "6. limma / limma-voom 差异分析(芯片或已标准化数据)",
341
+ "7. 协变量校正与多重检验",
342
+ "8. 火山图",
343
+ "9. 表达热图",
344
+ "10. 基因集 Venn 与区域归属表",
345
+ ],
346
+ },
347
+ {
348
+ "module": "功能富集",
349
+ "items": [
350
+ "11. GO 过表达分析(BP/CC/MF)",
351
+ "12. KEGG 通路富集",
352
+ "13. Reactome 通路富集",
353
+ "14. GSEA(完整排序列表)",
354
+ "15. GSVA / ssGSEA 单样本通路打分",
355
+ ],
356
+ },
357
+ {
358
+ "module": "预后模型",
359
+ "items": [
360
+ "16. LASSO-Cox 变量筛选与风险评分",
361
+ "17. 高低危分组生存曲线",
362
+ "18. 时间依赖 ROC",
363
+ "19. 列线图",
364
+ "20. Bootstrap 校准曲线",
365
+ "21. 决策曲线分析(DCA)",
366
+ ],
367
+ },
368
+ ]
369
+ return {
370
+ "context": args.get("context", "general"),
371
+ "title": "Omics 分析工作台",
372
+ "groups": groups,
373
+ "count": sum(len(group["items"]) for group in groups),
374
+ "routing": {
375
+ "species": "物种必须显式确认,人鼠基因符号不通用。",
376
+ "data_type": "count 走 DESeq2/edgeR;芯片、TPM 或已 log2 数据走 limma。判断错会导致整条结果链失效。",
377
+ "single_cell": "Seurat/CellChat/Monocle 属于小时级重流程,不在工具层运行;生成脚本交用户执行,再把下游结果拿回来分析。",
378
+ "clinical_stats": "普通临床表格统计、基础 KM 与单变量 Cox 应交给独立统计工具;本 kit 不依赖其他连接器。",
379
+ "integrity": "基因符号、通路条目、模型系数只能来自工具输出,不得由模型凭记忆产生。",
380
+ },
381
+ }
382
+
383
+
384
+ HANDLERS: dict[str, Callable[[dict[str, Any]], Any]] = {
385
+ "doctor": doctor,
386
+ "deg": deg,
387
+ "enrich": enrich,
388
+ "plot": plot,
389
+ "survival": survival,
390
+ "capabilities": feature_menu,
391
+ }
monadomics/cli.py ADDED
@@ -0,0 +1,80 @@
1
+ import argparse
2
+ import json
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from . import __version__, backend
8
+
9
+
10
+ ANALYSES = ("deg", "enrich", "plot", "survival")
11
+
12
+
13
+ def main(argv=None):
14
+ parser = argparse.ArgumentParser(prog="monadomics", description="Local R-backed bioinformatics analysis.")
15
+ parser.add_argument("--version", action="version", version=f"monadomics {__version__}")
16
+ commands = parser.add_subparsers(dest="command", required=True)
17
+ doctor_parser = commands.add_parser("doctor", help="Check R and required packages without installing anything.")
18
+ doctor_parser.add_argument("--group", choices=["all", *backend.R_PACKAGE_GROUPS], default="all")
19
+ commands.add_parser("capabilities", help="List the 21 supported capabilities.")
20
+ schema_parser = commands.add_parser("schema", help="Show an analysis command's JSON input schema.")
21
+ schema_parser.add_argument("analysis", choices=ANALYSES)
22
+ setup_parser = commands.add_parser("setup-r", help="Install R packages for a selected analysis group.")
23
+ setup_parser.add_argument("group", choices=["all", *backend.R_PACKAGE_GROUPS])
24
+ for name in ANALYSES:
25
+ analysis = commands.add_parser(name, help=next(item["description"] for item in backend.COMMANDS if item["name"] == name))
26
+ analysis.add_argument("--params", required=True, help="UTF-8 JSON file, or - for standard input. Relative data paths use the current working directory.")
27
+ analysis.add_argument("--output-dir", type=Path, help="Artifact directory; otherwise OMICS_OUTPUT_DIR or ~/.workbuddy/workspace/omics.")
28
+ analysis.add_argument("--timeout", type=int, default=600 if name == "plot" else 900, help="Maximum R execution time in seconds.")
29
+ args = parser.parse_args(argv)
30
+
31
+ try:
32
+ if args.command == "doctor":
33
+ result = backend.doctor({"group": args.group})
34
+ ok = result.get("ready", False)
35
+ elif args.command == "capabilities":
36
+ result = backend.feature_menu({})
37
+ ok = True
38
+ elif args.command == "schema":
39
+ result = next(item for item in backend.COMMANDS if item["name"] == args.analysis)
40
+ ok = True
41
+ elif args.command == "setup-r":
42
+ packages = sorted({package for group in backend.R_PACKAGE_GROUPS.values() for package in group}) if args.group == "all" else sorted(set(backend.R_PACKAGE_GROUPS["core"] + backend.R_PACKAGE_GROUPS[args.group]))
43
+ process = subprocess.run(
44
+ [backend._find_rscript(), "--vanilla", str(backend.R_DIR / "bootstrap.R"), *packages],
45
+ stdout=sys.stderr, stderr=sys.stderr, check=False,
46
+ )
47
+ if process.returncode:
48
+ raise RuntimeError(f"R package installation failed (exit {process.returncode}); see stderr.")
49
+ result = backend.doctor({"group": args.group})
50
+ ok = result.get("ready", False)
51
+ else:
52
+ if args.timeout <= 0:
53
+ raise ValueError("--timeout must be a positive number of seconds.")
54
+ params = json.loads(sys.stdin.read() if args.params == "-" else Path(args.params).expanduser().read_text(encoding="utf-8-sig"))
55
+ if not isinstance(params, dict):
56
+ raise ValueError("--params must contain a JSON object.")
57
+ schema = next(item["inputSchema"] for item in backend.COMMANDS if item["name"] == args.command)
58
+ missing = [key for key in schema.get("required", []) if key not in params]
59
+ if missing:
60
+ raise ValueError(f"Missing required parameters: {', '.join(missing)}")
61
+ unknown = sorted(set(params) - set(schema["properties"]))
62
+ if unknown:
63
+ raise ValueError(f"Unknown parameters: {', '.join(unknown)}. See monadomics schema {args.command}.")
64
+ for key, value in params.items():
65
+ allowed = schema["properties"][key].get("enum")
66
+ if allowed and value not in allowed:
67
+ raise ValueError(f"{key} must be one of: {', '.join(map(str, allowed))}")
68
+ if key.endswith("_path"):
69
+ if not isinstance(value, str):
70
+ raise ValueError(f"{key} must be a file path string.")
71
+ params[key] = str(Path(value).expanduser().resolve())
72
+ params["timeout"] = args.timeout
73
+ backend.OUTPUT_DIR = (args.output_dir or backend.OUTPUT_DIR).expanduser().resolve()
74
+ result = backend.HANDLERS[args.command](params)
75
+ ok = True
76
+ print(json.dumps({"ok": bool(ok), **result}, ensure_ascii=False, allow_nan=False))
77
+ return 0 if ok else 1
78
+ except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as exc:
79
+ print(json.dumps({"ok": False, "error": type(exc).__name__, "message": str(exc)}, ensure_ascii=False))
80
+ return 1
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env Rscript
2
+ # Install the R packages MonadOmics bioinformatics tools depend on.
3
+ #
4
+ # Rscript r/bootstrap.R # core + deg + enrich + plot + survival
5
+ # Rscript r/bootstrap.R enrich # one group
6
+ # Rscript r/bootstrap.R DESeq2 limma # explicit packages
7
+ # Rscript r/bootstrap.R --check # report status, install nothing
8
+ #
9
+ # Run separately from the connector init: Bioconductor installation can take
10
+ # more than 20 minutes.
11
+
12
+ GROUPS <- list(
13
+ core = c("jsonlite", "ggplot2", "svglite"),
14
+ deg = c("DESeq2", "edgeR", "limma"),
15
+ enrich = c("clusterProfiler", "org.Hs.eg.db", "org.Mm.eg.db", "ReactomePA", "GSVA"),
16
+ # svglite backs ggsave's SVG device; without it every figure call fails at save time.
17
+ plot = c("edgeR", "pheatmap", "ggrepel", "ggvenn", "svglite"),
18
+ survival = c("survival", "glmnet", "timeROC", "rms")
19
+ )
20
+
21
+ DEFAULT_GROUPS <- c("core", "deg", "enrich", "plot", "survival")
22
+
23
+ # Bioconductor packages need BiocManager; everything else comes from CRAN.
24
+ BIOC <- c(
25
+ "DESeq2", "edgeR", "limma", "clusterProfiler",
26
+ "org.Hs.eg.db", "org.Mm.eg.db", "ReactomePA", "GSVA"
27
+ )
28
+
29
+ resolve <- function(argv) {
30
+ argv <- argv[argv != "--check"]
31
+ if (!length(argv)) return(unique(unlist(GROUPS[DEFAULT_GROUPS])))
32
+ out <- character()
33
+ for (item in argv) {
34
+ out <- c(out, if (item %in% names(GROUPS)) GROUPS[[item]] else item)
35
+ }
36
+ unique(out)
37
+ }
38
+
39
+ status <- function(pkgs) {
40
+ vapply(pkgs, requireNamespace, logical(1), quietly = TRUE)
41
+ }
42
+
43
+ report <- function(pkgs) {
44
+ have <- status(pkgs)
45
+ for (pkg in pkgs) {
46
+ cat(sprintf("%-20s %s\n", pkg, if (have[[pkg]]) "OK" else "MISSING"))
47
+ }
48
+ cat(sprintf("\n%d/%d installed\n", sum(have), length(have)))
49
+ invisible(have)
50
+ }
51
+
52
+ warn_if_source_only <- function() {
53
+ if (!identical(getOption("pkgType"), "source")) return(invisible(NULL))
54
+ cat("\n! This R build installs every package from source.\n")
55
+ cat(" Platform:", R.version$platform, "\n")
56
+ cat(" CRAN and Bioconductor ship macOS binaries for the official CRAN build only,\n")
57
+ cat(" so a Homebrew R compiles the whole dependency tree and needs system libraries\n")
58
+ cat(" (cmake, imagemagick, hdf5, ...). Installing the CRAN build of R is far more\n")
59
+ cat(" reliable for Bioconductor: https://cran.r-project.org/bin/macosx/\n\n")
60
+ }
61
+
62
+ main <- function() {
63
+ argv <- commandArgs(trailingOnly = TRUE)
64
+ pkgs <- resolve(argv)
65
+
66
+ if ("--check" %in% argv) {
67
+ report(pkgs)
68
+ return(invisible(NULL))
69
+ }
70
+
71
+ if (getRversion() < "4.2.0") {
72
+ stop("R 4.2 or newer is required.", call. = FALSE)
73
+ }
74
+
75
+ options(repos = c(CRAN = "https://cloud.r-project.org"))
76
+ # Annotation packages run to hundreds of MB; the 60s default aborts them
77
+ # mid-download on slower or unstable connections.
78
+ options(timeout = max(getOption("timeout"), 1800))
79
+ warn_if_source_only()
80
+ missing <- pkgs[!status(pkgs)]
81
+ if (!length(missing)) {
82
+ cat("All requested R packages are already installed.\n")
83
+ return(invisible(NULL))
84
+ }
85
+
86
+ user_lib <- strsplit(Sys.getenv("R_LIBS_USER"), .Platform$path.sep, fixed = TRUE)[[1]][1]
87
+ if (is.na(user_lib) || !nzchar(user_lib)) {
88
+ stop("Set R_LIBS_USER to a writable personal R library before installing packages.", call. = FALSE)
89
+ }
90
+ user_lib <- path.expand(user_lib)
91
+ dir.create(user_lib, recursive = TRUE, showWarnings = FALSE)
92
+ if (file.access(user_lib, 2) != 0) stop("R_LIBS_USER is not writable: ", user_lib, call. = FALSE)
93
+ .libPaths(c(user_lib, .libPaths()))
94
+
95
+ cat(sprintf("Installing %d package(s): %s\n", length(missing), paste(missing, collapse = ", ")))
96
+
97
+ bioc_missing <- intersect(missing, BIOC)
98
+ cran_missing <- setdiff(missing, BIOC)
99
+
100
+ if (length(cran_missing)) {
101
+ utils::install.packages(cran_missing, lib = user_lib, Ncpus = max(1L, parallel::detectCores() - 1L))
102
+ }
103
+ if (length(bioc_missing)) {
104
+ if (!requireNamespace("BiocManager", quietly = TRUE)) {
105
+ utils::install.packages("BiocManager", lib = user_lib)
106
+ }
107
+ BiocManager::install(bioc_missing, lib = user_lib, ask = FALSE, update = FALSE, force = TRUE)
108
+ }
109
+
110
+ cat("\nFinal status:\n")
111
+ have <- report(pkgs)
112
+ if (!all(have)) {
113
+ cat("\nSome packages failed to install. Read the log above for the first error;\n")
114
+ cat("system libraries (e.g. gfortran, libxml2) are the usual cause on macOS.\n")
115
+ quit(status = 1, save = "no")
116
+ }
117
+ }
118
+
119
+ main()