gwasstudio 2.12.1__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.
gwasstudio/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ import importlib.metadata
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ import pandas as pd
6
+ from cloup import Context, HelpFormatter, HelpTheme, Style
7
+ from loguru import logger as a_logger
8
+ from platformdirs import user_config_dir, user_data_dir, user_log_dir
9
+
10
+ # https://pandas.pydata.org/pandas-docs/stable/user_guide/copy_on_write.html#
11
+ pd.options.mode.copy_on_write = True
12
+
13
+ __all__ = [
14
+ "__appname__",
15
+ "__version__",
16
+ "context_settings",
17
+ "config_dir",
18
+ "config_filename",
19
+ "data_dir",
20
+ "log_file",
21
+ "logger",
22
+ "mongo_db_path",
23
+ "mongo_db_logpath",
24
+ ]
25
+
26
+ __appname__ = __name__
27
+
28
+ try:
29
+ __version__ = importlib.metadata.version(__appname__)
30
+ except importlib.metadata.PackageNotFoundError:
31
+ __version__ = "unknown"
32
+
33
+ default_log_dir = Path(user_log_dir(__appname__))
34
+ try:
35
+ default_log_dir.mkdir(parents=True, exist_ok=True)
36
+ test_file = default_log_dir / ".write_test"
37
+ test_file.write_text("test")
38
+ test_file.unlink()
39
+ dir_writable = True
40
+ except Exception:
41
+ dir_writable = False
42
+
43
+ if dir_writable:
44
+ log_dir = Path(user_log_dir(__appname__))
45
+ config_dir = Path(user_config_dir(__appname__))
46
+ data_dir = Path(user_data_dir(__appname__)) / "data"
47
+ else:
48
+ tmp_root = Path(tempfile.gettempdir())
49
+ base_dir = tmp_root / f"{__appname__}_tmp"
50
+ log_dir = base_dir / "log"
51
+ config_dir = base_dir / "config"
52
+ data_dir = base_dir / "data"
53
+
54
+ log_dir.mkdir(parents=True, exist_ok=True)
55
+ config_dir.mkdir(parents=True, exist_ok=True)
56
+ data_dir.mkdir(parents=True, exist_ok=True)
57
+
58
+ log_file = log_dir / "gwasstudio.log"
59
+ config_filename = "config.yaml"
60
+ mongo_db_path = data_dir / "mongo_db"
61
+ mongo_db_logpath = log_dir / "mongod.log"
62
+
63
+ mongo_db_path.mkdir(parents=True, exist_ok=True)
64
+
65
+ # Check the docs for all available arguments of HelpFormatter and HelpTheme.
66
+ formatter_settings = HelpFormatter.settings(
67
+ theme=HelpTheme(
68
+ invoked_command=Style(fg="red"),
69
+ heading=Style(fg="bright_white", bold=True),
70
+ constraint=Style(fg="magenta"),
71
+ col1=Style(fg="yellow"),
72
+ )
73
+ )
74
+
75
+ context_settings = Context.settings(formatter_settings=formatter_settings)
76
+
77
+ logger = a_logger
78
+ logger.remove()
@@ -0,0 +1,7 @@
1
+ from .export import export
2
+ from .info import info
3
+ from .ingest import ingest
4
+ from .list import list_projects
5
+ from .metadata.query import query_metadata
6
+
7
+ __all__ = ["export", "info", "ingest", "list_projects", "query_metadata"]
@@ -0,0 +1,422 @@
1
+ import math
2
+ from pathlib import Path
3
+ from typing import Callable
4
+
5
+ import click
6
+ import cloup
7
+ import pandas as pd
8
+ import tiledb
9
+ from dask import delayed, compute
10
+ from dask.distributed import Client
11
+
12
+ from gwasstudio import logger
13
+ from gwasstudio.dask_client import manage_daskcluster, dask_deployment_types
14
+ from gwasstudio.methods.extraction_methods import extract_full_stats, extract_regions_snps
15
+ from gwasstudio.methods.locus_breaker import _process_locusbreaker
16
+ from gwasstudio.mongo.models import EnhancedDataProfile
17
+ from gwasstudio.utils import check_file_exists, write_table
18
+ from gwasstudio.utils.cfg import get_mongo_uri, get_tiledb_config, get_dask_batch_size, get_dask_deployment
19
+ from gwasstudio.utils.enums import MetadataEnum
20
+ from gwasstudio.utils.io import read_to_bed
21
+ from gwasstudio.utils.metadata import load_search_topics, query_mongo_obj, dataframe_from_mongo_objs
22
+ from gwasstudio.utils.mongo_manager import manage_mongo
23
+ from gwasstudio.utils.path_joiner import join_path
24
+
25
+
26
+ def create_output_prefix_dict(df: pd.DataFrame, output_prefix: str, source_id_column: str) -> dict:
27
+ """
28
+ Generates a dictionary mapping data IDs to output prefixes based on column values.
29
+
30
+ Parameters:
31
+ df (pd.DataFrame): Input DataFrame containing the required columns.
32
+ output_prefix (str): Prefix to prepend to output filenames.
33
+ source_id_column (str): Column name containing source id
34
+
35
+ Returns:
36
+ dict: Dictionary with 'data_id' as keys and corresponding output prefixes as values.
37
+ """
38
+ logger.debug("Creating output prefix dictionary")
39
+ key_column = "data_id"
40
+ value_column = "output_prefix"
41
+
42
+ # Determine the column to use for prefixing
43
+ column_to_get = source_id_column if source_id_column in df.columns else key_column
44
+ logger.debug(f"Selected column for prefixing: {column_to_get}")
45
+
46
+ # Construct the output prefix column with fallback
47
+ df[value_column] = f"{output_prefix}_" + df[column_to_get].fillna(df[key_column]).astype(str)
48
+
49
+ # Create dictionary mapping data IDs to prefixes
50
+ output_prefix_dict = df.set_index(key_column)[value_column].to_dict()
51
+ logger.debug("Output prefix dictionary created")
52
+
53
+ return output_prefix_dict
54
+
55
+
56
+ def _process_function_tasks(
57
+ tiledb_uri: str,
58
+ tiledb_cfg: dict[str, str],
59
+ group: pd.DataFrame,
60
+ attr: str,
61
+ batch_size: int,
62
+ output_prefix_dict: dict[str, str],
63
+ output_format: str,
64
+ *,
65
+ function_name: Callable,
66
+ regions_snps: str | None = None,
67
+ dask_client: Client = None,
68
+ **kwargs,
69
+ ) -> None:
70
+ """
71
+ Schedule and execute delayed export tasks.
72
+
73
+ Parameters
74
+ ----------
75
+ tiledb_uri : str
76
+ URI of the TileDB array (e.g. ``s3://my-bucket/dataset``).
77
+ The array is opened *inside* each worker, never serialized.
78
+ function_name : Callable
79
+ One of the extraction functions (``extract_full_stats``, …).
80
+ """
81
+ # Check Dask client
82
+ if dask_client is None:
83
+ raise ValueError("Missing Dask client")
84
+
85
+ # Wrapper that opens the array locally and forwards the call.
86
+ @delayed
87
+ def _run_extraction(
88
+ uri: str,
89
+ cfg: dict[str, str],
90
+ trait: str,
91
+ out_prefix: str | None,
92
+ **inner_kwargs,
93
+ ) -> pd.DataFrame:
94
+ """Open the TileDB array on the worker and invoke ``function_name``."""
95
+ # Open a *read‑only* handle on the worker.
96
+ with tiledb.open(uri, mode="r", config=cfg) as arr:
97
+ # ``function_name`` expects the opened array as its first argument.
98
+ return function_name(arr, trait, out_prefix, **inner_kwargs)
99
+
100
+ def _run_transformation(gwas_df: pd.DataFrame, meta_df: pd.DataFrame, trait_id: str) -> pd.DataFrame:
101
+ # Optional metadata broadcast – only used when ``skip_meta`` is False.
102
+ if isinstance(group, pd.Series):
103
+ return gwas_df
104
+
105
+ id_col = "data_id"
106
+ meta_row = meta_df.loc[meta_df[id_col] == trait_id].squeeze()
107
+ # meta_dict = meta_row.squeeze().to_dict()
108
+ meta_dict = {f"meta_{k}": v for k, v in meta_row.drop(["data_id", "output_prefix"]).to_dict().items()}
109
+
110
+ broadcast = {col: [val] * len(gwas_df) for col, val in meta_dict.items()}
111
+ return gwas_df.assign(**broadcast)
112
+
113
+ # Prepare kwargs for the downstream extraction routine.
114
+ kwargs["attributes"] = attr.split(",") if attr else None
115
+
116
+ if regions_snps:
117
+ kwargs["regions_snps"] = delayed(read_to_bed)(regions_snps)
118
+
119
+ trait_id_list = group["data_id"].tolist() if not isinstance(group, pd.Series) else group.tolist()
120
+
121
+ # Build the delayed tasks – each task receives the URI, not the object.
122
+ tasks = []
123
+ if function_name.__name__ == "_process_locusbreaker":
124
+ # Locusbreaker returns a tuple (segments, intervals).
125
+ for trait in trait_id_list:
126
+ delayed_tuple = _run_extraction(
127
+ tiledb_uri,
128
+ tiledb_cfg,
129
+ trait,
130
+ None,
131
+ **kwargs,
132
+ )
133
+
134
+ # Extract the two DataFrames lazily.
135
+ seg = delayed(lambda t: t[0])(delayed_tuple)
136
+ intv = delayed(lambda t: t[1])(delayed_tuple)
137
+
138
+ # write each DataFrame (still delayed)
139
+ seg_task = delayed(write_table)(
140
+ seg,
141
+ f"{output_prefix_dict.get(trait)}_segments",
142
+ logger,
143
+ output_format,
144
+ index=False,
145
+ )
146
+ int_task = delayed(write_table)(
147
+ intv,
148
+ f"{output_prefix_dict.get(trait)}_intervals",
149
+ logger,
150
+ file_format=output_format,
151
+ index=False,
152
+ )
153
+ tasks.extend([seg_task, int_task])
154
+ else:
155
+ for trait in trait_id_list:
156
+ extracted_df = _run_extraction(
157
+ tiledb_uri,
158
+ tiledb_cfg,
159
+ trait,
160
+ output_prefix_dict.get(trait),
161
+ **kwargs,
162
+ )
163
+ transformed_df = delayed(_run_transformation)(extracted_df, group, trait)
164
+ result = delayed(write_table)(
165
+ transformed_df, output_prefix_dict.get(trait), logger, file_format=output_format, index=False
166
+ )
167
+ tasks.append(result)
168
+
169
+ # Handle single-batch case if batch_size <= 0 or len(tasks) <= batch_size
170
+ if batch_size <= 0 or len(tasks) <= batch_size:
171
+ logger.info(f"Running all tasks in a single batch ({len(tasks)} items)")
172
+ compute(*tasks, scheduler=dask_client)
173
+ logger.info("Single batch completed.", flush=True)
174
+ else:
175
+ total_batches = math.ceil(len(tasks) / batch_size)
176
+ for i in range(0, len(tasks), batch_size):
177
+ batch_no = i // batch_size + 1
178
+ logger.info(f"Running batch {batch_no}/{total_batches} ({min(batch_size, len(tasks) - i)} items)")
179
+ compute(*tasks[i : i + batch_size], scheduler=dask_client)
180
+ logger.info(f"Batch {batch_no} completed.", flush=True)
181
+
182
+
183
+ HELP_DOC = """
184
+ Export summary statistics from TileDB datasets with various filtering options.
185
+ """
186
+
187
+
188
+ @cloup.command("export", no_args_is_help=True, help=HELP_DOC)
189
+ @cloup.option_group(
190
+ "TileDB options",
191
+ cloup.option("--uri", default="s3://tiledb", help="URI of the TileDB dataset"),
192
+ cloup.option("--output-prefix", default="out", help="Prefix for naming output files"),
193
+ cloup.option(
194
+ "--output-format", type=click.Choice(["parquet", "csv.gz", "csv"]), default="csv.gz", help="Output file format"
195
+ ),
196
+ cloup.option("--search-file", required=True, default=None, help="Input file for querying metadata"),
197
+ cloup.option(
198
+ "--attr",
199
+ required=True,
200
+ default="BETA,SE,EAF,MLOG10P,EA,NEA",
201
+ help="string delimited by comma with the attributes to export",
202
+ ),
203
+ )
204
+ @cloup.option_group(
205
+ "Locusbreaker options",
206
+ cloup.option("--locusbreaker", default=False, is_flag=True, help="Option to run locusbreaker"),
207
+ cloup.option("--pvalue-sig", default=5.0, help="Maximum log p-value threshold within the window"),
208
+ cloup.option("--pvalue-limit", default=3.3, help="Log p-value threshold for loci borders"),
209
+ cloup.option(
210
+ "--hole-size",
211
+ default=250000,
212
+ help="Minimum pair-base distance between SNPs in different loci (default: 250000)",
213
+ ),
214
+ cloup.option(
215
+ "--maf",
216
+ default=0.01,
217
+ help="MAF filter to apply before locusbreaker",
218
+ ),
219
+ cloup.option(
220
+ "--phenovar",
221
+ default=False,
222
+ is_flag=True,
223
+ help="Boolean to compute phenovariance (Work in progress, not fully implemented yet)",
224
+ ),
225
+ cloup.option(
226
+ "--locus-flanks",
227
+ default=100000,
228
+ help="Flanking regions (in bp) to extend each locus in both directions (default: 100000)",
229
+ ),
230
+ )
231
+ @cloup.option_group(
232
+ "Regions or SNP ID filtering options",
233
+ cloup.option(
234
+ "--get-regions-snps",
235
+ default=None,
236
+ help="Bed (or CHR,POS) file with regions or SNP list to filter",
237
+ ),
238
+ cloup.option(
239
+ "--skip-meta",
240
+ default=False,
241
+ is_flag=True,
242
+ help="Do not add metadata columns (default: False)",
243
+ ),
244
+ cloup.option(
245
+ "--nest",
246
+ default=False,
247
+ is_flag=True,
248
+ help="Estimate effective population size (Work in progress, not fully implemented yet)",
249
+ ),
250
+ )
251
+ @cloup.option_group(
252
+ "P-value filtering options",
253
+ cloup.option(
254
+ "--pvalue-thr",
255
+ default=0,
256
+ help="Minimum -log10(p-value) threshold to filter significant SNPs",
257
+ ),
258
+ )
259
+ @cloup.option_group(
260
+ "Option to plot results",
261
+ cloup.option(
262
+ "--plot-out",
263
+ default=False,
264
+ is_flag=True,
265
+ help="Boolean to plot results. If enabled, the output will be plotted as a Manhattan plot.",
266
+ ),
267
+ cloup.option(
268
+ "--color-thr",
269
+ default="red",
270
+ help="Color for the points passing the threshold line in the plot (default: red)",
271
+ ),
272
+ cloup.option(
273
+ "--s-value",
274
+ default=5,
275
+ help="Value for the suggestive p-value line in the plot (default: 5)",
276
+ ),
277
+ )
278
+ @cloup.option_group(
279
+ "Option to query metadata before export",
280
+ cloup.option(
281
+ "--case-sensitive",
282
+ default=False,
283
+ is_flag=True,
284
+ help="Perform case-sensitive matching on query values (default: False).",
285
+ ),
286
+ cloup.option(
287
+ "--exact-match",
288
+ default=False,
289
+ is_flag=True,
290
+ help="Perform exact match on query values (default: False).",
291
+ ),
292
+ )
293
+ @click.pass_context
294
+ def export(
295
+ ctx: click.Context,
296
+ uri: str,
297
+ search_file: str,
298
+ attr: str,
299
+ output_prefix: str,
300
+ output_format: str,
301
+ pvalue_sig: float,
302
+ pvalue_limit: float,
303
+ pvalue_thr: float,
304
+ hole_size: int,
305
+ phenovar: bool,
306
+ nest: bool,
307
+ maf: float,
308
+ locus_flanks: int,
309
+ locusbreaker: bool,
310
+ get_regions_snps: str | None,
311
+ skip_meta: bool,
312
+ plot_out: bool,
313
+ color_thr: str,
314
+ s_value: int,
315
+ case_sensitive: bool,
316
+ exact_match: bool,
317
+ ) -> None:
318
+ """Export summary statistics based on selected options."""
319
+ cfg = get_tiledb_config(ctx)
320
+ if not check_file_exists(search_file, logger):
321
+ exit(1)
322
+
323
+ search_topics, output_fields = load_search_topics(search_file)
324
+ if plot_out:
325
+ # plot_config = get_plot_config(ctx)
326
+ # if not plot_config:
327
+ # logger.error("Plotting configuration is required for plotting output.")
328
+ # exit(1)
329
+ if "data_ids" not in search_topics:
330
+ logger.error("Plotting option is enabled but no data_ids is provided in the search file.")
331
+ exit(1)
332
+ if len(search_topics["data_ids"]) > 20:
333
+ logger.error(
334
+ "Plotting option is enabled but too many data_ids are provided in the search file. Please limit to 20 data_ids."
335
+ )
336
+ exit(1)
337
+ # Query MongoDB
338
+ with manage_mongo(ctx):
339
+ mongo_uri = get_mongo_uri(ctx)
340
+ obj = EnhancedDataProfile(uri=mongo_uri)
341
+ objs = query_mongo_obj(search_topics, obj, case_sensitive=case_sensitive, exact_match=exact_match)
342
+
343
+ meta_df = dataframe_from_mongo_objs(output_fields, objs)
344
+
345
+ # Write metadata query result
346
+ path = Path(output_prefix)
347
+ output_path = path.with_suffix("").with_name(path.stem + "_meta")
348
+ kwargs = {"index": False}
349
+ log_msg = f"{len(objs)} results found. Writing to {output_path}.csv"
350
+ write_table(meta_df, str(output_path), logger, file_format="csv", log_msg=log_msg, **kwargs)
351
+
352
+ # Create an output prefix dictionary to generate output filenames
353
+ source_id_column = MetadataEnum.get_source_id_field()
354
+ output_prefix_dict = create_output_prefix_dict(meta_df, output_prefix, source_id_column=source_id_column)
355
+
356
+ # Process according to selected options
357
+ if get_dask_deployment(ctx) not in dask_deployment_types:
358
+ logger.error(f"A valid dask deployment type must be set from: {dask_deployment_types}")
359
+ raise SystemExit(1)
360
+
361
+ with manage_daskcluster(ctx) as client:
362
+ batch_size = get_dask_batch_size(ctx)
363
+ grouped = meta_df.groupby(MetadataEnum.get_tiledb_grouping_fields(), observed=False)
364
+ for name, group in grouped:
365
+ group_name = "_".join(name)
366
+ logger.info(f"Processing the group {group_name}")
367
+ tiledb_uri = join_path(uri, group_name)
368
+ logger.debug(f"tiledb_uri: {tiledb_uri}")
369
+
370
+ # Build a per‑group output‑prefix dict
371
+ _output_prefix_dict = {
372
+ key: f"{output_prefix}_{group_name}_{value[len(output_prefix) + 1 :]}"
373
+ for key, value in output_prefix_dict.items()
374
+ }
375
+
376
+ _meta_df = group if not skip_meta else group["data_id"]
377
+
378
+ # Common argument list
379
+ common_args = [
380
+ tiledb_uri, # <-- URI, not an opened array
381
+ cfg,
382
+ _meta_df,
383
+ attr,
384
+ batch_size,
385
+ _output_prefix_dict,
386
+ output_format,
387
+ ]
388
+
389
+ # Dispatch the appropriate extraction routine
390
+ match (locusbreaker, get_regions_snps):
391
+ case (True, _):
392
+ _process_function_tasks(
393
+ *common_args,
394
+ function_name=_process_locusbreaker,
395
+ maf=maf,
396
+ hole_size=hole_size,
397
+ pvalue_sig=pvalue_sig,
398
+ pvalue_limit=pvalue_limit,
399
+ phenovar=phenovar,
400
+ locus_flanks=locus_flanks,
401
+ dask_client=client,
402
+ )
403
+ case (_, str() as bed_fp):
404
+ _process_function_tasks(
405
+ *common_args,
406
+ function_name=extract_regions_snps,
407
+ regions_snps=bed_fp,
408
+ plot_out=plot_out,
409
+ color_thr=color_thr,
410
+ s_value=s_value,
411
+ dask_client=client,
412
+ )
413
+ case _:
414
+ _process_function_tasks(
415
+ *common_args,
416
+ function_name=extract_full_stats,
417
+ pvalue_thr=pvalue_thr,
418
+ plot_out=plot_out,
419
+ color_thr=color_thr,
420
+ s_value=s_value,
421
+ dask_client=client,
422
+ )
gwasstudio/cli/info.py ADDED
@@ -0,0 +1,19 @@
1
+ import click
2
+ import cloup
3
+
4
+ from gwasstudio import __appname__, __version__, config_dir, data_dir, log_dir
5
+
6
+ help_doc = """
7
+ Show GWASStudio details
8
+ """
9
+
10
+
11
+ @cloup.command("info", no_args_is_help=False, help=help_doc)
12
+ def info():
13
+ click.echo("{}, version {}\n".format(__appname__.capitalize(), __version__))
14
+
15
+ paths = {"config dir": config_dir, "data dir": data_dir, "log dir": log_dir}
16
+ click.echo("Paths: ")
17
+ for k, v in paths.items():
18
+ click.echo(" {}: {}".format(k, v))
19
+ click.echo("\n")