hogprof 0.3.0__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.
hogprof/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ name = "HogProf"
2
+
3
+ from importlib.metadata import version
4
+ # expose HogProf's version
5
+ __version__ = version("HogProf")
hogprof/cli.py ADDED
@@ -0,0 +1,484 @@
1
+ """User-facing console I/O for command-line tools"""
2
+
3
+ import argparse
4
+ import glob
5
+ import logging
6
+ import sys
7
+ import time
8
+ from collections.abc import Iterable
9
+ from datetime import timedelta
10
+ from pathlib import Path
11
+ from typing import TypeVar
12
+
13
+ import polars as pl
14
+ from rich.console import Console
15
+ from rich.logging import RichHandler
16
+ from rich.panel import Panel
17
+ from rich.progress import track
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+ from rich.traceback import install as install_rich_traceback
21
+
22
+ from hogprof import __version__
23
+ from hogprof.presets import DB_PRESETS
24
+
25
+ _Item = TypeVar("_Item")
26
+
27
+ # Rich Console shared by both logging and progress
28
+ console = Console(stderr=True)
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def setup_cli(verbose: bool = False,
33
+ *,
34
+ output_console: Console = console) -> None:
35
+ """Configure application logging and terminal tracebacks for a CLI run."""
36
+ level = logging.DEBUG if verbose else logging.INFO
37
+
38
+ handler = RichHandler(
39
+ console=output_console,
40
+ markup=False,
41
+ rich_tracebacks=True,
42
+ tracebacks_show_locals=verbose,
43
+ show_path=verbose,
44
+ )
45
+ logging.basicConfig(
46
+ level=level,
47
+ format="%(message)s",
48
+ datefmt="[%X]",
49
+ handlers=[handler],
50
+ force=True,
51
+ )
52
+
53
+ # if in console (not redirected to file),
54
+ # format error traceback nicely with Rich
55
+ if output_console.is_terminal:
56
+ install_rich_traceback(console=output_console,
57
+ show_locals=verbose,
58
+ word_wrap=True)
59
+
60
+ def print_startup(args=None, *, output_console: Console = console) -> None:
61
+ """Print the banner and, when supplied, the initial CLI configuration."""
62
+
63
+ table = Table.grid(padding=(0, 3))
64
+ table.add_column(style="dim")
65
+ table.add_column()
66
+
67
+ header = Table.grid(padding=(0, 1))
68
+ header.add_column(justify="center")
69
+ header.add_column()
70
+
71
+ logo = Text("┌─┼─┐\n■ □ ■", style="bold cyan")
72
+ name = Text.assemble(
73
+ ("HogProf", "bold cyan"),
74
+ (f" v{__version__}", "dim"),
75
+ )
76
+
77
+ header.add_row(logo, name)
78
+
79
+ table.add_row(header)
80
+ table.add_row()
81
+ table.add_row(
82
+ "Cite",
83
+ "[link=https://doi.org/10.1371/journal.pcbi.1007553]"
84
+ "Moi et al. (2020), PLOS Comp Biol[/link]",
85
+ )
86
+ if args is not None:
87
+ source = (
88
+ f"OMA: {args['oma']}" if args["oma"]
89
+ else f"OrthoXML: {args['orthoxml_glob']}"
90
+ if args["orthoxml_glob"]
91
+ else f"OrthoXML tar: {args['orthoxml_tar']}"
92
+ )
93
+ table.add_section()
94
+ table.add_row()
95
+ table.add_row("Input", source)
96
+ table.add_row("Output", str(args["output_dir"]))
97
+ table.add_row("Workers", str(args["njobs"]))
98
+ table.add_row("Seed", str(args["seed"]))
99
+
100
+ output_console.print(
101
+ Panel(table,
102
+ title_align="left",
103
+ subtitle_align="right",
104
+ border_style="cyan",
105
+ padding=(1, 2),
106
+ expand=False,
107
+ )
108
+ )
109
+ output_console.print("\n")
110
+ if args is not None:
111
+ output_console.print("[dim]Initializing...[/]")
112
+ output_console.print("\n")
113
+
114
+
115
+ def track_progress(
116
+ sequence: Iterable[_Item],
117
+ *,
118
+ description: str,
119
+ total: int | None = None,
120
+ output_console: Console = console,
121
+ ) -> Iterable[_Item]:
122
+ """Iterate with a Rich progress bar when output is an interactive terminal."""
123
+
124
+ if output_console.is_terminal:
125
+ # If output is CLI, it's all simple -- use Rich
126
+ yield from track(
127
+ sequence,
128
+ description=description,
129
+ total=total,
130
+ console=output_console,
131
+ disable=not output_console.is_terminal,
132
+ )
133
+
134
+ else:
135
+ # Otherwise if output is redirected to file (e.g. SLURM)
136
+ # -- report progress differently
137
+ if total is None:
138
+ try:
139
+ total = len(sequence)
140
+ except TypeError:
141
+ pass
142
+
143
+ start = last_log = time.monotonic()
144
+ last_percent = 0
145
+ count = 0
146
+
147
+ # how often in % to report
148
+ log_step = 10
149
+ # if log_step takes too long, how often in seconds to report anyway
150
+ # -- every 1 min
151
+ log_interval = 1 * 60
152
+
153
+ first_message = f"{description}: 0% " + f"(0/{total})" if total else ""
154
+ logger.info(first_message)
155
+ for count, item in enumerate(sequence, 1):
156
+
157
+ # First, yield item -- to feed the generator
158
+ yield item
159
+
160
+ now = time.monotonic()
161
+ elapsed = now - start
162
+
163
+ if total:
164
+ percent = min(100, int(100 * count / total))
165
+ due = percent >= last_percent + log_step
166
+ else:
167
+ percent = None
168
+ due = True
169
+
170
+ if due or now - last_log >= log_interval:
171
+ if total:
172
+ eta = elapsed / count * (total - count)
173
+ eta_str = str(timedelta(seconds=round(eta)))
174
+ logger.info(
175
+ "%s: %d%% (%d/%d) | ETA %s",
176
+ description, percent, count, total, eta_str,
177
+ )
178
+ last_percent = percent
179
+ else:
180
+ logger.info(
181
+ "%s: %d items | %.1f items/s",
182
+ description, count, count / elapsed,
183
+ )
184
+
185
+ last_log = now
186
+
187
+ logger.info(
188
+ "%s: done (%d items in %.1fs)",
189
+ description, count, time.monotonic() - start,
190
+ )
191
+
192
+
193
+ def print_search_result(df: pl.DataFrame) -> None:
194
+ """Pretty print for `hogprof search` results"""
195
+ stdout = Console(file=sys.stdout)
196
+
197
+ if not stdout.is_terminal:
198
+ df.write_csv(sys.stdout, separator="\t")
199
+ return
200
+
201
+ table = Table(show_header=True, header_style="bold cyan", show_lines=False)
202
+
203
+ for column in df.columns:
204
+ table.add_column(
205
+ column,
206
+ justify="right" if df.schema[column].is_numeric() else "left",
207
+ )
208
+
209
+ for row in df.iter_rows():
210
+ table.add_row(*(
211
+ f"{value:.4f}" if isinstance(value, float)
212
+ else "—" if value is None
213
+ else str(value)
214
+ for value in row
215
+ ))
216
+
217
+ stdout.print(table)
218
+
219
+
220
+ class RichArgumentParser(argparse.ArgumentParser):
221
+ """Render argparse output with Rich while preserving streams and exit codes."""
222
+
223
+ def print_help(self, file=None):
224
+ file = sys.stdout if file is None else file
225
+ print_startup(output_console=Console(file=file))
226
+ super().print_help(file)
227
+
228
+ def _print_message(self, message, file=None):
229
+ if message:
230
+ text = Text(message)
231
+ text.highlight_regex(r"(?<!\w)--?[\w-]+", style="cyan")
232
+ text.highlight_regex(
233
+ r"(?m)^(?:usage|options|positional arguments):", style="bold"
234
+ )
235
+ if message.startswith(f"{self.prog}: error:"):
236
+ text.stylize("bold red")
237
+ Console(file=sys.stderr if file is None else file).print(
238
+ text, end="", soft_wrap=True
239
+ )
240
+
241
+ def _validate_args(parser, parsed_args):
242
+ if parsed_args.reformat_names:
243
+ parser.error('--reformat-names is not supported in this version')
244
+
245
+ if parsed_args.orthoxml_tar is not None:
246
+ parser.error('--orthoxml-tar is not supported in this version; '
247
+ 'extract the archive and use --orthoxml-glob')
248
+
249
+ # check inputs
250
+ for option, path in (('--oma', parsed_args.oma), ('--species-tree', parsed_args.tree)):
251
+ if path is not None and not path.is_file():
252
+ parser.error(f'{option}: input file does not exist or is not a file: {path}')
253
+
254
+ # check Glob if provided
255
+ if parsed_args.orthoxml_glob is not None:
256
+ matches = glob.glob(parsed_args.orthoxml_glob)
257
+ if not matches:
258
+ parser.error('--orthoxml-glob: pattern does not match any input files')
259
+
260
+ if any(not Path(path).is_file() for path in matches):
261
+ parser.error('--orthoxml-glob: pattern must match only input files')
262
+
263
+
264
+ if parsed_args.tax_weights is not None:
265
+ for suffix in ('.json', '.h5'):
266
+ path = Path(parsed_args.tax_weights + suffix)
267
+ if not path.is_file():
268
+ parser.error(f'--tax-weights: model file does not exist or is not a file: {path}')
269
+
270
+ if parsed_args.output_dir.exists() and not parsed_args.output_dir.is_dir():
271
+ parser.error(f'--output-dir: path is not a directory: {parsed_args.output_dir}')
272
+
273
+ for option, value in (('--jobs', parsed_args.njobs),
274
+ ('--num-permutations', parsed_args.num_permutations)):
275
+ if value < 1:
276
+ parser.error(f'{option} must be greater than zero')
277
+
278
+ if parsed_args.min_species < 0:
279
+ parser.error('--min-species must be nonnegative')
280
+
281
+ #if parsed_args.min_events < 0:
282
+ # parser.error('--min-events must be nonnegative')
283
+
284
+ def add_build_arguments(parser):
285
+ # Input files
286
+ # - 'source' is one of three options: OMA DB, tarfile with .orthoxml, or glob
287
+ source = parser.add_mutually_exclusive_group(required=True)
288
+ source.add_argument('--oma',
289
+ '--OMA', # compatibility option name; to remove
290
+ dest='oma',
291
+ help='Path to the OMA database file', type=Path)
292
+ source.add_argument('--orthoxml-glob',
293
+ '--OrthoGlob', # compatibility option name; to remove
294
+ dest='orthoxml_glob',
295
+ type=str,
296
+ help='A glob expression for orthoxml files')
297
+ source.add_argument('--orthoxml-tar',
298
+ '--tarfile', # compatibility option name; to remove
299
+ dest='orthoxml_tar',
300
+ type=Path,
301
+ help=argparse.SUPPRESS)
302
+
303
+ # - species tree
304
+ parser.add_argument('--species-tree', '--tree',
305
+ '--mastertree', # compatibility option name; to remove
306
+ dest='tree',
307
+ help='Newick species tree with node names matching the OrthoXML',
308
+ type=Path, required=True)
309
+
310
+ # Output
311
+ parser.add_argument('--output-dir', '--output',
312
+ '--outpath', # compatibility option name; to remove
313
+ '-o',
314
+ dest='output_dir',
315
+ help='Output directory path', type=Path, required=True)
316
+
317
+ # Configuration options
318
+ parser.add_argument('--tax-weights',
319
+ '--taxweights', # compatibility option name; to remove
320
+ dest='tax_weights',
321
+ help=argparse.SUPPRESS,
322
+ type=str
323
+ )
324
+ parser.add_argument('--taxon-mask',
325
+ '--taxmask', # compatibility option name; to remove
326
+ dest='taxon_mask',
327
+ help='Keep only this clade, identified by its tree node name (e.g. Sauria)',
328
+ type=str)
329
+ parser.add_argument('--exclude-taxa',
330
+ '--taxfilter', # compatibility option name; to remove
331
+ dest='exclude_taxa',
332
+ help='Tree node names of clades to exclude',
333
+ type=str, nargs='+')
334
+ parser.add_argument('--min-species',
335
+ '--specieslim', # compatibility option name; to remove
336
+ dest='min_species',
337
+ help='Species threshold: root HOGs must exceed it; subHOGs must meet it',
338
+ type=int, default=10)
339
+ parser.add_argument('--min-events',
340
+ '--eventslim', # compatibility option name; to remove
341
+ dest='min_events',
342
+ help='Keep subHOGs exceeding this loss or duplication count (-1 keeps all)',
343
+ type=int, default=0)
344
+ parser.add_argument('--db-type',
345
+ '--dbtype', # compatibility option name; to remove
346
+ dest='db_type',
347
+ help='Taxonomic range preset; --taxon-mask and --exclude-taxa override it',
348
+ choices=DB_PRESETS)
349
+ parser.add_argument('--num-permutations',
350
+ '--nperm', # compatibility option name; to remove
351
+ dest='num_permutations',
352
+ help='Number of hash functions used to construct each profile',
353
+ type=int, default=256)
354
+ parser.add_argument('--seed',
355
+ type=int, default=None,
356
+ help="Random seed for reproducibility (default: random)")
357
+
358
+ # Flags
359
+ events = parser.add_mutually_exclusive_group()
360
+ events.add_argument('--loss-only',
361
+ '--lossonly', # compatibility option name; to remove
362
+ dest='loss_only',
363
+ help='Only compile loss events', action='store_true')
364
+ events.add_argument('--duplication-only',
365
+ '--duplonly', # compatibility option name; to remove
366
+ dest='duplication_only',
367
+ help='Only compile duplication events', action='store_true')
368
+ parser.add_argument('--use-tax-ids',
369
+ '--taxcodes', # compatibility option name; to remove
370
+ dest='use_tax_ids',
371
+ help='Match species to tree nodes by NCBI taxon ID instead of species name',
372
+ action='store_true')
373
+ parser.add_argument('--slice-subhogs',
374
+ '--slicesubhogs', # compatibility option name; to remove
375
+ dest='slice_subhogs',
376
+ help='Make profiles for subHOGs', action='store_true')
377
+
378
+ # Retain unsupported legacy options only to give an actionable CLI error.
379
+ parser.add_argument('--reformat-names',
380
+ '--reformat_names',
381
+ dest='reformat_names',
382
+ help=argparse.SUPPRESS,
383
+ action='store_true')
384
+
385
+ # Other options
386
+ parser.add_argument('--jobs', '--njobs', '-j',
387
+ '--nthreads', # compatibility option name; to remove
388
+ dest="njobs",
389
+ help="Number of worker processes", type=int, default=1)
390
+
391
+ parser.add_argument('--verbose', '-v', help='Print verbose output', action='store_true')
392
+ return parser
393
+
394
+
395
+ def add_search_arguments(parser):
396
+ parser.add_argument("hog_id",
397
+ help="HOG ID for similarity search",
398
+ type=str),
399
+ parser.add_argument("--k", "-k",
400
+ help="Number of nearest neighbors to consider",
401
+ type=int, default=10)
402
+ parser.add_argument("--level",
403
+ help="Taxonomic level at which the query is defined."
404
+ "Used for ambiguous HOG names defined at"
405
+ "multiple levels.",
406
+ type=str)
407
+ parser.add_argument("--index-dir",
408
+ help="Directory containing HogProf index files. "
409
+ "Produced by `build`", type=str, required=True)
410
+ parser.add_argument("--oma", help="Path to OmaServer.h5 file", type=str)
411
+ parser.add_argument("--nsamples", help="no of samples", type=int, default=256)
412
+ parser.add_argument("--slice-subhogs",
413
+ help="Use slice_subhogs mode",
414
+ action="store_true")
415
+ return parser
416
+
417
+
418
+ def run_build(parsed_args):
419
+ if parsed_args.verbose:
420
+ setup_cli(verbose=True)
421
+
422
+ # Suppress pyham's noisy INFO messages, including during initialization.
423
+ logging.getLogger('pyham').setLevel(logging.WARNING)
424
+
425
+ print_startup(vars(parsed_args))
426
+
427
+ build_args = vars(parsed_args).copy()
428
+ del build_args['reformat_names']
429
+ del build_args['orthoxml_tar']
430
+ del build_args['command']
431
+ del build_args['func']
432
+
433
+ # import and run as late as possible to not stagger CLI
434
+ from hogprof.lshbuilder import run
435
+ run(**build_args)
436
+
437
+
438
+ def run_search(parsed_args):
439
+ setup_cli(verbose=False)
440
+
441
+ search_args = vars(parsed_args).copy()
442
+ del search_args["command"]
443
+ del search_args["func"]
444
+
445
+ from hogprof.profiler import run_search
446
+ results = run_search(**search_args)
447
+ print_search_result(results)
448
+
449
+
450
+ def main(argv=None):
451
+ # setup Rich CLI before the parser. This is to provide
452
+ # rich-formatted output for argparse argument validation, --help, etc.
453
+ setup_cli()
454
+ parser = RichArgumentParser(prog="hogprof")
455
+
456
+ parser.add_argument(
457
+ "--version",
458
+ action="version",
459
+ version=f"%(prog)s {__version__}",
460
+ )
461
+
462
+ # set up the 'build' command
463
+ subparsers = parser.add_subparsers(dest="command", required=True)
464
+ builder_parser = subparsers.add_parser("build", help="Build a searchable LSH index")
465
+ add_build_arguments(builder_parser)
466
+ builder_parser.set_defaults(func=run_build)
467
+
468
+ # set up the `search` command
469
+ search_parser = subparsers.add_parser("search", help="Query an LSH index")
470
+ add_search_arguments(search_parser)
471
+ search_parser.set_defaults(func=run_search)
472
+
473
+ # parse and go
474
+ argv = sys.argv[1:] if argv is None else argv
475
+ args = parser.parse_args(argv if argv else ["-h"])
476
+ if args.command == "build":
477
+ _validate_args(builder_parser, args)
478
+ args.func(args)
479
+
480
+ return 0
481
+
482
+
483
+ if __name__ == '__main__':
484
+ main()