hca-schema-validator 0.2.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.
Files changed (55) hide show
  1. hca_schema_validator/__init__.py +11 -0
  2. hca_schema_validator/_vendored/__init__.py +18 -0
  3. hca_schema_validator/_vendored/cellxgene_schema/LICENSE +20 -0
  4. hca_schema_validator/_vendored/cellxgene_schema/__init__.py +1 -0
  5. hca_schema_validator/_vendored/cellxgene_schema/atac_seq.py +675 -0
  6. hca_schema_validator/_vendored/cellxgene_schema/cli.py +206 -0
  7. hca_schema_validator/_vendored/cellxgene_schema/env.py +9 -0
  8. hca_schema_validator/_vendored/cellxgene_schema/gencode.py +163 -0
  9. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/.gitignore +3 -0
  10. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/drosophila_melanogaster_diff.txt +42 -0
  11. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/gene_info.md +29 -0
  12. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/gene_info.yml +51 -0
  13. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_caenorhabditis_elegans.csv.gz +0 -0
  14. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_callithrix_jacchus.csv.gz +0 -0
  15. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_danio_rerio.csv.gz +0 -0
  16. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_drosophila_melanogaster.csv.gz +0 -0
  17. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_ercc.csv.gz +0 -0
  18. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_gorilla_gorilla.csv.gz +0 -0
  19. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_homo_sapiens.csv.gz +0 -0
  20. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_macaca_fascicularis.csv.gz +0 -0
  21. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_macaca_mulatta.csv.gz +0 -0
  22. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_microcebus_murinus.csv.gz +0 -0
  23. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_mus_musculus.csv.gz +0 -0
  24. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_oryctolagus_cuniculus.csv.gz +0 -0
  25. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_pan_troglodytes.csv.gz +0 -0
  26. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_rattus_norvegicus.csv.gz +0 -0
  27. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_sars_cov_2.csv.gz +0 -0
  28. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/genes_sus_scrofa.csv.gz +0 -0
  29. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/homo_sapiens_diff.txt +1237 -0
  30. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/mus_musculus_diff.txt +549 -0
  31. hca_schema_validator/_vendored/cellxgene_schema/gencode_files/rattus_norvegicus_diff.txt +7306 -0
  32. hca_schema_validator/_vendored/cellxgene_schema/map_species.py +40 -0
  33. hca_schema_validator/_vendored/cellxgene_schema/matrix_utils.py +134 -0
  34. hca_schema_validator/_vendored/cellxgene_schema/migrate.py +145 -0
  35. hca_schema_validator/_vendored/cellxgene_schema/migrate_files/automigrate_terms.json +2 -0
  36. hca_schema_validator/_vendored/cellxgene_schema/migrate_files/donor_updates.json +2 -0
  37. hca_schema_validator/_vendored/cellxgene_schema/migrate_files/non_csr_list.txt +54 -0
  38. hca_schema_validator/_vendored/cellxgene_schema/migrate_files/private_non_csr.json +1 -0
  39. hca_schema_validator/_vendored/cellxgene_schema/migrate_files/title_donor_updates.json +2 -0
  40. hca_schema_validator/_vendored/cellxgene_schema/ontology_files/.gitignore +1 -0
  41. hca_schema_validator/_vendored/cellxgene_schema/ontology_parser.py +3 -0
  42. hca_schema_validator/_vendored/cellxgene_schema/remove_labels.py +74 -0
  43. hca_schema_validator/_vendored/cellxgene_schema/schema.py +21 -0
  44. hca_schema_validator/_vendored/cellxgene_schema/schema_definitions/schema_definition.yaml +841 -0
  45. hca_schema_validator/_vendored/cellxgene_schema/utils.py +242 -0
  46. hca_schema_validator/_vendored/cellxgene_schema/validate.py +2389 -0
  47. hca_schema_validator/_vendored/cellxgene_schema/validation_internals/__init__.py +0 -0
  48. hca_schema_validator/_vendored/cellxgene_schema/validation_internals/check_duplicates.py +87 -0
  49. hca_schema_validator/_vendored/cellxgene_schema/write_labels.py +403 -0
  50. hca_schema_validator/schema_definitions/hca_schema_definition.yaml +839 -0
  51. hca_schema_validator/validator.py +47 -0
  52. hca_schema_validator-0.2.0.dist-info/METADATA +116 -0
  53. hca_schema_validator-0.2.0.dist-info/RECORD +55 -0
  54. hca_schema_validator-0.2.0.dist-info/WHEEL +4 -0
  55. hca_schema_validator-0.2.0.dist-info/licenses/NOTICE +15 -0
@@ -0,0 +1,675 @@
1
+ import contextlib
2
+ import gzip
3
+ import logging
4
+ import os
5
+ import shutil
6
+ import subprocess as sp
7
+ import tempfile
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from pathlib import Path
10
+ from typing import Iterable, List, Optional, Sequence, Tuple
11
+
12
+ import anndata as ad
13
+ import h5py
14
+ import ibis
15
+ import pyarrow as pa
16
+ import pyarrow.csv
17
+ import pyarrow.dataset
18
+ import pysam
19
+
20
+ from .ontology_parser import ONTOLOGY_PARSER
21
+ from .utils import GB, is_ontological_descendant_of
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # TODO: these chromosome tables should be calculated from the fasta file?
26
+ # location of fasta https://www.gencodegenes.org/human/release_44.html and file name GRCh38.primary_assembly.genome.fa
27
+ human_chromosome_by_length = {
28
+ "chr1": 248956422,
29
+ "chr2": 242193529,
30
+ "chr3": 198295559,
31
+ "chr4": 190214555,
32
+ "chr5": 181538259,
33
+ "chr6": 170805979,
34
+ "chr7": 159345973,
35
+ "chr8": 145138636,
36
+ "chr9": 138394717,
37
+ "chr10": 133797422,
38
+ "chr11": 135086622,
39
+ "chr12": 133275309,
40
+ "chr13": 114364328,
41
+ "chr14": 107043718,
42
+ "chr15": 101991189,
43
+ "chr16": 90338345,
44
+ "chr17": 83257441,
45
+ "chr18": 80373285,
46
+ "chr19": 58617616,
47
+ "chr20": 64444167,
48
+ "chr21": 46709983,
49
+ "chr22": 50818468,
50
+ "chrX": 156040895,
51
+ "chrY": 57227415,
52
+ "chrM": 16569,
53
+ "GL000009.2": 201709,
54
+ "GL000194.1": 191469,
55
+ "GL000195.1": 182896,
56
+ "GL000205.2": 185591,
57
+ "GL000213.1": 164239,
58
+ "GL000216.2": 176608,
59
+ "GL000218.1": 161147,
60
+ "GL000219.1": 179198,
61
+ "GL000220.1": 161802,
62
+ "GL000225.1": 211173,
63
+ "KI270442.1": 392061,
64
+ "KI270711.1": 42210,
65
+ "KI270713.1": 40745,
66
+ "KI270721.1": 100316,
67
+ "KI270726.1": 43739,
68
+ "KI270727.1": 448248,
69
+ "KI270728.1": 1872759,
70
+ "KI270731.1": 150754,
71
+ "KI270733.1": 179772,
72
+ "KI270734.1": 165050,
73
+ "KI270744.1": 168472,
74
+ "KI270750.1": 148850,
75
+ }
76
+ mouse_chromosome_by_length = {
77
+ "chr1": 195154279,
78
+ "chr2": 181755017,
79
+ "chr3": 159745316,
80
+ "chr4": 156860686,
81
+ "chr5": 151758149,
82
+ "chr6": 149588044,
83
+ "chr7": 144995196,
84
+ "chr8": 130127694,
85
+ "chr9": 124359700,
86
+ "chr10": 130530862,
87
+ "chr11": 121973369,
88
+ "chr12": 120092757,
89
+ "chr13": 120883175,
90
+ "chr14": 125139656,
91
+ "chr15": 104073951,
92
+ "chr16": 98008968,
93
+ "chr17": 95294699,
94
+ "chr18": 90720763,
95
+ "chr19": 61420004,
96
+ "chrX": 169476592,
97
+ "chrY": 91455967,
98
+ "chrM": 16299,
99
+ "GL456210.1": 169725,
100
+ "GL456211.1": 241735,
101
+ "GL456212.1": 153618,
102
+ "GL456219.1": 175968,
103
+ "GL456221.1": 206961,
104
+ "GL456239.1": 40056,
105
+ "GL456354.1": 195993,
106
+ "GL456372.1": 28664,
107
+ "GL456381.1": 25871,
108
+ "GL456385.1": 35240,
109
+ "JH584295.1": 1976,
110
+ "JH584296.1": 199368,
111
+ "JH584297.1": 205776,
112
+ "JH584298.1": 184189,
113
+ "JH584299.1": 953012,
114
+ "JH584303.1": 158099,
115
+ "JH584304.1": 114452,
116
+ }
117
+ organism_ontology_term_id_by_chromosome_length_table = {
118
+ "NCBITaxon:9606": human_chromosome_by_length,
119
+ "NCBITaxon:10090": mouse_chromosome_by_length,
120
+ }
121
+ column_ordering = ["chromosome", "start coordinate", "stop coordinate", "barcode", "read support"]
122
+ schema = pa.schema(
123
+ [
124
+ pa.field("chromosome", pa.string()),
125
+ pa.field("start coordinate", pa.int64()),
126
+ pa.field("stop coordinate", pa.int64()),
127
+ pa.field("barcode", pa.string()),
128
+ pa.field("read support", pa.int64()),
129
+ ]
130
+ )
131
+
132
+
133
+ def log_calls(func):
134
+ def wrapper(*args, **kwargs):
135
+ logging.info("starting: %s", func.__name__)
136
+ return func(*args, **kwargs)
137
+
138
+ return wrapper
139
+
140
+
141
+ def count_lines_in_compressed_file(file_path: str) -> int:
142
+ """
143
+ Count lines in a compressed file (gzip or bgzip).
144
+
145
+ :param file_path: Path to the compressed file
146
+ :return: Number of lines in the file
147
+ """
148
+ line_count = 0
149
+ with gzip.open(file_path, "rt") as f:
150
+ for _ in f:
151
+ line_count += 1
152
+ return line_count
153
+
154
+
155
+ def is_atac(x: str) -> str:
156
+ if is_ontological_descendant_of(ONTOLOGY_PARSER, x, "EFO:0010891"):
157
+ if is_ontological_descendant_of(ONTOLOGY_PARSER, x, "EFO:0008913"):
158
+ return "p" # paired
159
+ else:
160
+ return "u" # unpaired
161
+ else:
162
+ return "n" # not atac seq
163
+
164
+
165
+ def check_anndata_requires_fragment(anndata_file: str) -> bool:
166
+ """
167
+ Check if an anndata file requires a fragment file to be valid. The anndata file requires a fragment file if the
168
+ assay_ontology_term_id are all descendants of EFO:0010891 and EFO:0008913. If the assay_ontology_term_id are all
169
+ descendants of EFO:0010891 and not EFO:0008913, the anndata file does not require a fragment file. If the
170
+ assay_ontology_term_id are not all descendants of EFO:0010891, an error is raised and a fragment file is not allowed
171
+ because the anndata is not a valid ATAC-Seq file.
172
+
173
+ :param anndata_file: The anndata file to validate.
174
+ :return:
175
+ """
176
+ with h5py.File(anndata_file) as f:
177
+ assay_ontology_term_ids = ad.io.read_elem(f["obs"])["assay_ontology_term_id"]
178
+ df = assay_ontology_term_ids.map(is_atac)
179
+
180
+ if (df == "p").all():
181
+ return False
182
+ elif (df == "u").all():
183
+ return True
184
+ if (df == "n").any():
185
+ raise ValueError("Anndata.obs.assay_ontology_term_id are not all descendants of EFO:0010891.")
186
+ else:
187
+ raise ValueError("Anndata.obs.assay_ontology_term_id has mixed paired and unpaired assay terms.")
188
+
189
+
190
+ def process_fragment(
191
+ fragment_file: str,
192
+ anndata_file: str,
193
+ generate_index: bool = False,
194
+ output_file: Optional[str] = None,
195
+ ) -> list[str]:
196
+ """
197
+ Validate the fragment against the anndata file and generate the index if the fragment is valid.
198
+
199
+ :param str fragment_file: The fragment file to process
200
+ :param str anndata_file: The anndata file to validate against
201
+ :param bool generate_index: Whether to generate the index for the fragment
202
+ :param override_write_algorithm: Override the write algorithm used to write the bgzip file. Options are "pysam"
203
+ and "cli"
204
+ :param output_file: The output file to write the bgzip file to. If not provided, the output file will be the same
205
+
206
+ """
207
+
208
+ with tempfile.TemporaryDirectory() as tempdir:
209
+ # quick checks
210
+ errors = validate_anndata(anndata_file)
211
+ if errors:
212
+ return errors
213
+
214
+ # convert the fragment to a parquet file for faster processing
215
+ try:
216
+ parquet_file = convert_to_parquet(fragment_file, tempdir)
217
+ except Exception as e:
218
+ msg = "Error Parsing the fragment file. Check that columns match schema definition. Error: " + str(e)
219
+ logger.exception(msg)
220
+ return [msg]
221
+
222
+ organism_ontology_term_id = None
223
+ with h5py.File(anndata_file) as f:
224
+ organism_ontology_term_id = ad.io.read_elem(f["uns"]).get("organism_ontology_term_id")
225
+
226
+ # slow checks
227
+ errors = validate_anndata_with_fragment(parquet_file, anndata_file, organism_ontology_term_id)
228
+ if errors:
229
+ return errors
230
+ else:
231
+ logger.info("Fragment and Anndata file are valid")
232
+
233
+ # generate the index
234
+ if generate_index:
235
+ logger.info(f"Sorting fragment and generating index for {fragment_file}")
236
+ index_fragment(fragment_file, output_file)
237
+
238
+ logger.debug("cleaning up")
239
+ return []
240
+
241
+
242
+ def convert_to_parquet(fragment_file: str, tempdir: str) -> str:
243
+ """
244
+ Convert the fragment file to a parquet dataset for faster processing.
245
+
246
+ :param fragment_file: A gzipped compressed fragment file
247
+ :param tempdir: The temporary directory to write the parquet file to. Name of the written file is derived from
248
+ the input.
249
+ """
250
+ logger.info(f"Converting {fragment_file} to parquet")
251
+ parquet_file_path = Path(tempdir) / Path(fragment_file).name.replace(".gz", ".parquet").replace(".bgz", ".parquet")
252
+ pa.dataset.write_dataset(
253
+ data=pa.csv.open_csv(
254
+ pa.input_stream(fragment_file, compression="gzip", buffer_size=GB),
255
+ read_options=pa.csv.ReadOptions(column_names=schema.names),
256
+ parse_options=pa.csv.ParseOptions(delimiter="\t"),
257
+ convert_options=pa.csv.ConvertOptions(column_types=schema),
258
+ ),
259
+ base_dir=parquet_file_path,
260
+ format="parquet",
261
+ # Using hive partitioning for best compatibility with dask to_parquet and read_parquet functions
262
+ partitioning=pa.dataset.partitioning(pa.schema([pa.field("chromosome", pa.string())]), flavor="hive"),
263
+ )
264
+
265
+ return str(parquet_file_path)
266
+
267
+
268
+ def report_errors(header: str, errors: list[str]) -> list[str]:
269
+ if any(errors):
270
+ errors = [e for e in errors if e is not None]
271
+ errors = [header] + errors
272
+ logger.error("\n\t".join(errors))
273
+ return errors
274
+ else:
275
+ return []
276
+
277
+
278
+ def validate_anndata(anndata_file: str) -> list[str]:
279
+ errors = [validate_anndata_organism_ontology_term_id(anndata_file), validate_anndata_is_primary_data(anndata_file)]
280
+ return report_errors("Errors found in Anndata file. Skipping fragment validation.", errors)
281
+
282
+
283
+ def validate_anndata_with_fragment(parquet_file: str, anndata_file: str, organism_ontology_term_id: str) -> list[str]:
284
+ errors = [
285
+ validate_fragment_start_coordinate_greater_than_0(parquet_file),
286
+ validate_fragment_barcode_in_adata_index(parquet_file, anndata_file),
287
+ validate_fragment_stop_coordinate_within_chromosome(parquet_file, organism_ontology_term_id),
288
+ validate_fragment_stop_greater_than_start_coordinate(parquet_file),
289
+ validate_fragment_read_support(parquet_file),
290
+ validate_fragment_no_duplicate_rows(parquet_file),
291
+ ]
292
+ return report_errors("Errors found in Fragment and/or Anndata file", errors)
293
+
294
+
295
+ @log_calls
296
+ def validate_fragment_no_duplicate_rows(parquet_file: str) -> Optional[str]:
297
+ t = ibis.read_parquet(f"{parquet_file}/**", hive_partitioning=True)
298
+ rows_per_chromosome = t["chromosome"].value_counts().execute()
299
+ msg = ""
300
+ # Checking number of unique rows per chromosome is more memory efficient than checking all rows at once
301
+ for chromosome, count in rows_per_chromosome.itertuples(index=False):
302
+ n_unique = t.filter(t["chromosome"] == chromosome).distinct().count().execute()
303
+ if n_unique != count:
304
+ if not msg:
305
+ msg = "Fragment file has duplicate rows.\n"
306
+ msg += f"Chromosome {chromosome} has {count} rows but only {n_unique} are unique\n"
307
+ if msg:
308
+ return msg.strip() # remove trailing newline
309
+
310
+
311
+ @log_calls
312
+ def validate_fragment_start_coordinate_greater_than_0(parquet_file: str) -> Optional[str]:
313
+ df = ibis.read_parquet(f"{parquet_file}/**", hive_partitioning=True)
314
+ if not (df["start coordinate"] > 0).all().execute():
315
+ return "Start coordinate must be greater than 0."
316
+
317
+
318
+ @log_calls
319
+ def validate_fragment_barcode_in_adata_index(parquet_file: str, anndata_file: str) -> Optional[str]:
320
+ df = ibis.read_parquet(f"{parquet_file}/**", hive_partitioning=True)
321
+ barcode = set(df.select("barcode").distinct().execute()["barcode"])
322
+ with h5py.File(anndata_file) as f:
323
+ obs = ad.io.read_elem(f["obs"])
324
+ if set(obs.index) != barcode:
325
+ return "Barcodes don't match anndata.obs.index"
326
+
327
+
328
+ @log_calls
329
+ def validate_fragment_stop_greater_than_start_coordinate(parquet_file: str) -> Optional[str]:
330
+ df = ibis.read_parquet(f"{parquet_file}/**", hive_partitioning=True)
331
+ if not (df["stop coordinate"] > df["start coordinate"]).all().execute():
332
+ return "Stop coordinate must be greater than start coordinate."
333
+
334
+
335
+ @log_calls
336
+ def validate_fragment_stop_coordinate_within_chromosome(
337
+ parquet_file: str, organism_ontology_term_id: str
338
+ ) -> Optional[str]:
339
+ chromosome_length_table = organism_ontology_term_id_by_chromosome_length_table.get(organism_ontology_term_id)
340
+ t = ibis.read_parquet(f"{parquet_file}/**", hive_partitioning=True)
341
+ df = t.group_by("chromosome").aggregate(max_stop_coordinate=t["stop coordinate"].max()).execute()
342
+
343
+ mismatched_chromosomes = set(df["chromosome"].unique()) - chromosome_length_table.keys()
344
+ if mismatched_chromosomes:
345
+ return f"Chromosomes in the fragment do not match the organism({organism_ontology_term_id}).\n" + "\t\n".join(
346
+ mismatched_chromosomes
347
+ )
348
+
349
+ df["chromosome_length"] = df["chromosome"].map(chromosome_length_table)
350
+ if not (df["max_stop_coordinate"] <= df["chromosome_length"]).all():
351
+ return "Stop coordinate must be less than the chromosome length."
352
+
353
+
354
+ @log_calls
355
+ def validate_fragment_read_support(parquet_file: str) -> Optional[str]:
356
+ t = ibis.read_parquet(f"{parquet_file}/**", hive_partitioning=True)
357
+ if (t["read support"] <= 0).any().execute():
358
+ return "Read support must be greater than 0."
359
+
360
+
361
+ @log_calls
362
+ def validate_anndata_is_primary_data(anndata_file: str) -> Optional[str]:
363
+ with h5py.File(anndata_file) as f:
364
+ is_primary_data = ad.io.read_elem(f["obs"])["is_primary_data"]
365
+ if not is_primary_data.all():
366
+ return "Anndata.obs.is_primary_data must all be True."
367
+
368
+
369
+ @log_calls
370
+ def validate_anndata_organism_ontology_term_id(anndata_file: str) -> Optional[str]:
371
+ organism_ontology_term_id = None
372
+ with h5py.File(anndata_file) as f:
373
+ organism_ontology_term_id = ad.io.read_elem(f["uns"]).get("organism_ontology_term_id")
374
+ allowed_terms = [*organism_ontology_term_id_by_chromosome_length_table.keys()]
375
+ if organism_ontology_term_id not in allowed_terms:
376
+ return f"Anndata.obs.organism_ontology_term_id must be one of {allowed_terms}. Got {organism_ontology_term_id}."
377
+
378
+
379
+ @log_calls
380
+ def detect_chromosomes(parquet_file: str) -> list[str]:
381
+ t = ibis.read_parquet(f"{parquet_file}/**", hive_partitioning=True)
382
+ chromosomes = list(t.select(["chromosome"]).distinct().execute()["chromosome"])
383
+ chromosomes.sort() # sort chromosomes to ensure consistent order
384
+ return chromosomes
385
+
386
+
387
+ def get_output_file(fragment_file: str, output_file: Optional[str]) -> str:
388
+ if not output_file:
389
+ bgzip_output_file = fragment_file.replace(".gz", ".bgz")
390
+ elif not output_file.endswith(".bgz"):
391
+ bgzip_output_file = output_file + ".bgz"
392
+ else:
393
+ bgzip_output_file = output_file
394
+ return bgzip_output_file
395
+
396
+
397
+ def index_fragment(
398
+ fragment_file: str,
399
+ output_file: Optional[str] = None,
400
+ ):
401
+ # sort the fragment by chromosome, start coordinate, and stop coordinate, then compress it with bgzip
402
+ bgzip_output_file = get_output_file(fragment_file, output_file)
403
+ bgzip_output_path = Path(bgzip_output_file)
404
+ bgzip_output_path.unlink(missing_ok=True)
405
+
406
+ prepare_fragment(fragment_file, bgzip_output_file)
407
+ logger.info(f"Fragment sorted and compressed: {bgzip_output_file}")
408
+ pysam.tabix_index(bgzip_output_file, preset="bed", force=True)
409
+ tabix_output_file = bgzip_output_file + ".tbi"
410
+ logger.info(f"Index file generated: {tabix_output_file}")
411
+
412
+ # Validate line count between original and output files
413
+ logger.info("Validating line count between original and output files")
414
+
415
+ # Count lines in parallel to reduce processing time for large files
416
+ with ThreadPoolExecutor(max_workers=2) as executor:
417
+ original_future = executor.submit(count_lines_in_compressed_file, fragment_file)
418
+ output_future = executor.submit(count_lines_in_compressed_file, bgzip_output_file)
419
+
420
+ original_line_count = original_future.result()
421
+ output_line_count = output_future.result()
422
+
423
+ if original_line_count != output_line_count:
424
+ error_msg = f"Line count validation failed: original file has {original_line_count} lines, output file has {output_line_count} lines"
425
+ logger.error(error_msg)
426
+ raise ValueError(error_msg)
427
+
428
+ logger.info(f"Line count validation passed: {original_line_count} lines in both original and output files")
429
+
430
+
431
+ SORT_MEMORY_PERCENTAGE = 50
432
+
433
+
434
+ def _calculate_sort_memory(num_cores: int, sort_memory_percent: int) -> int:
435
+ """
436
+ Calculate the memory percentage to allocate per sort thread.
437
+
438
+ :param num_cores: Number of CPU cores available.
439
+ :param sort_memory_percent: Total percentage of system memory to allocate for sorting.
440
+ :return: Percentage of memory to allocate per sort thread.
441
+ """
442
+ effective_memory_pct = min(sort_memory_percent, 50) if num_cores > 1 else sort_memory_percent
443
+ return max(effective_memory_pct // num_cores, 1) # ensure at least 1% memory per core
444
+
445
+
446
+ def _ensure_tools(tools: Iterable[Tuple[str, str]]) -> None:
447
+ """Ensure required CLI tools are present in PATH."""
448
+ missing = [name for name, _ in tools if shutil.which(name) is None]
449
+ if missing:
450
+ msgs = dict(tools)
451
+ raise RuntimeError(" | ".join(f"Missing '{name}' in PATH; {msgs[name]}." for name in missing))
452
+
453
+
454
+ def _default_cores() -> int:
455
+ """Get CPU count, respecting container limits."""
456
+ # Try to read cgroup CPU limit (Docker/Kubernetes) - most reliable method
457
+ try:
458
+ # cgroup v1
459
+ with open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us") as f:
460
+ quota = int(f.read().strip())
461
+ with open("/sys/fs/cgroup/cpu/cpu.cfs_period_us") as f:
462
+ period = int(f.read().strip())
463
+ if quota > 0 and period > 0:
464
+ return max(1, quota // period)
465
+ except (FileNotFoundError, ValueError, PermissionError):
466
+ pass
467
+
468
+ try:
469
+ # cgroup v2
470
+ with open("/sys/fs/cgroup/cpu.max") as f:
471
+ content = f.read().strip()
472
+ if content != "max":
473
+ parts = content.split()
474
+ if len(parts) == 2:
475
+ quota, period = int(parts[0]), int(parts[1])
476
+ return max(1, quota // period)
477
+ except (FileNotFoundError, ValueError, PermissionError):
478
+ pass
479
+
480
+ # Fallback to system CPU count
481
+ return max(1, (os.cpu_count() or 1))
482
+
483
+
484
+ def _deterministic_env() -> dict:
485
+ # GNU coreutils behaviors (sort/uniq) are locale-sensitive; pin for determinism.
486
+ return {**os.environ, "LC_ALL": "C"}
487
+
488
+
489
+ def _sort_command(num_cores: int, sort_mem_pct: int) -> List[str]:
490
+ """Tab-delimited sort by (chrom, start, end, strand), numeric where needed, parallel, compressed temps via pigz."""
491
+ return [
492
+ "sort",
493
+ "--parallel",
494
+ str(num_cores),
495
+ "-t",
496
+ "\t",
497
+ "-k1,1",
498
+ "-k2,2n",
499
+ "-k3,3n",
500
+ "-k4,4",
501
+ "-S",
502
+ f"{sort_mem_pct}%",
503
+ "--compress-program",
504
+ "pigz",
505
+ ]
506
+
507
+
508
+ def _bgzip_command(num_cores: int) -> List[str]:
509
+ # Prefer long form; widely supported on recent htslib/bgzip.
510
+ return ["bgzip", f"--threads={num_cores}", "-c"]
511
+
512
+
513
+ def _pigz_decompress_command(path: Path) -> List[str]:
514
+ return ["pigz", "-dc", str(path)]
515
+
516
+
517
+ def _pipeline_run(
518
+ stages: Sequence[Tuple[str, List[str]]],
519
+ output_file: Path,
520
+ *,
521
+ env: Optional[dict] = None,
522
+ ) -> None:
523
+ """
524
+ Run a left-to-right streaming pipeline and write the last stage's stdout to output_file.
525
+ Each stage is (name, argv). Captures stderr per stage for error reporting.
526
+ """
527
+ if not stages:
528
+ raise ValueError("pipeline must have at least one stage")
529
+
530
+ procs: List[sp.Popen] = []
531
+
532
+ try:
533
+ # open sink
534
+ with open(output_file, "wb") as out_f:
535
+ # launch first
536
+ name0, cmd0 = stages[0]
537
+ logger.info(f"Starting pipeline stage: {name0}")
538
+ p0 = sp.Popen(cmd0, stdout=sp.PIPE, stderr=sp.PIPE, env=env)
539
+ procs.append(p0)
540
+
541
+ # chain middle (if any)
542
+ prev_stdout = p0.stdout
543
+ for stage_name, stage_cmd in stages[1:-1]:
544
+ logger.info(f"Starting pipeline stage: {stage_name}")
545
+ p = sp.Popen(stage_cmd, stdin=prev_stdout, stdout=sp.PIPE, stderr=sp.PIPE, env=env)
546
+ procs.append(p)
547
+ if prev_stdout is not None:
548
+ prev_stdout.close() # allow upstream SIGPIPE if we fail downstream
549
+ prev_stdout = p.stdout
550
+
551
+ # last -> file
552
+ last_name, last_cmd = stages[-1]
553
+ logger.info(f"Starting pipeline stage: {last_name}")
554
+ plast = sp.Popen(last_cmd, stdin=prev_stdout, stdout=out_f, stderr=sp.PIPE, env=env)
555
+ procs.append(plast)
556
+ if prev_stdout is not None:
557
+ prev_stdout.close()
558
+
559
+ plast.wait()
560
+
561
+ # collect return codes (downstream-first)
562
+ returncodes = []
563
+ for proc in reversed(procs):
564
+ rc = proc.returncode if proc is plast else proc.wait()
565
+ returncodes.append(rc)
566
+
567
+ # error aggregation
568
+ if any(rc != 0 for rc in returncodes):
569
+ errs = []
570
+
571
+ for (stage_name, _), proc in zip(stages, procs):
572
+ if proc.returncode != 0:
573
+ try:
574
+ err = proc.stderr.read().decode(errors="replace").strip() if proc.stderr else ""
575
+ except Exception:
576
+ err = ""
577
+
578
+ errs.append(f"{stage_name} failed (rc={proc.returncode}): {err}")
579
+
580
+ # Provide AWS Batch specific guidance
581
+ error_msg = " | ".join(errs)
582
+
583
+ raise RuntimeError(error_msg)
584
+
585
+ finally:
586
+ # harden against leaked children in edge cases
587
+ for p in procs:
588
+ if p.poll() is None:
589
+ with contextlib.suppress(Exception):
590
+ p.terminate()
591
+
592
+
593
+ def prepare_fragment(fragment_file: str, bgzip_output_file: str) -> None:
594
+ """
595
+ Decompress -> sort -> bgzip.
596
+ Sorts by (chrom, start, end, strand). Output is bgzipped; chromosomes may appear in non-input order,
597
+ but are internally sorted within chromosome.
598
+ """
599
+ _ensure_tools(
600
+ [
601
+ ("sort", "required to sort the fragment file"),
602
+ ("bgzip", "required to bgzip-compress the result"),
603
+ ("pigz", "required for parallel gzip (de)compression"),
604
+ ]
605
+ )
606
+
607
+ in_path = Path(fragment_file)
608
+ if not in_path.exists():
609
+ raise FileNotFoundError(f"Input file not found: {in_path}")
610
+
611
+ out_path = Path(bgzip_output_file)
612
+ ncores = _default_cores()
613
+ sort_mem_pct = _calculate_sort_memory(ncores, SORT_MEMORY_PERCENTAGE)
614
+ env = _deterministic_env()
615
+
616
+ stages = [
617
+ ("pigz -dc", _pigz_decompress_command(in_path)),
618
+ ("sort", _sort_command(ncores, sort_mem_pct)),
619
+ ("bgzip", _bgzip_command(ncores)),
620
+ ]
621
+
622
+ _pipeline_run(stages, out_path, env=env)
623
+ logger.info(f"bgzip compression completed successfully for {out_path}")
624
+
625
+
626
+ def deduplicate_fragment_rows(
627
+ fragment_file_name: str,
628
+ output_file_name: Optional[str] = None,
629
+ sort_memory_percent: int = SORT_MEMORY_PERCENTAGE,
630
+ ) -> str:
631
+ """
632
+ Decompress -> sort -> uniq -> bgzip.
633
+ Removes duplicate lines after sorting by (chrom, start, end, strand).
634
+
635
+ :param fragment_file_name: Path to the input gzipped fragment file.
636
+ :param output_file_name: Path for the output deduplicated bgzipped file. If None, a default name is generated.
637
+ :param sort_memory_percent: Percentage of system memory to allocate per sort thread.
638
+ :return: Path to the deduplicated bgzipped output file.
639
+ :raises RuntimeError: If the operation fails due to resource constraints.
640
+ """
641
+ _ensure_tools(
642
+ [
643
+ ("sort", "required to sort the fragment file"),
644
+ ("bgzip", "required to bgzip-compress the result"),
645
+ ("pigz", "required for parallel gzip (de)compression"),
646
+ ("uniq", "required to remove duplicate rows"),
647
+ ]
648
+ )
649
+
650
+ in_path = Path(fragment_file_name)
651
+ if not in_path.exists():
652
+ raise FileNotFoundError(f"Input file not found: {in_path}")
653
+
654
+ if output_file_name is None:
655
+ # <input minus .tsv[.gz]>_dedup.tsv.bgz
656
+ stem_once = in_path.stem # drops final suffix (e.g., ".gz")
657
+ stem_twice = Path(stem_once).stem # drops ".tsv" if present
658
+ out_path = in_path.with_name(f"{stem_twice}_dedup.tsv.bgz")
659
+ else:
660
+ out_path = Path(output_file_name)
661
+
662
+ ncores = _default_cores()
663
+ sort_mem_pct = _calculate_sort_memory(ncores, sort_memory_percent) # Output is smaller
664
+
665
+ stages = [
666
+ ("pigz -dc", _pigz_decompress_command(in_path)),
667
+ ("sort", _sort_command(ncores, sort_mem_pct)),
668
+ ("uniq", ["uniq"]),
669
+ ("bgzip", _bgzip_command(ncores)),
670
+ ]
671
+
672
+ logger.info(f"Starting deduplication pipeline: {ncores} cores, {sort_mem_pct}% memory per core")
673
+ _pipeline_run(stages, out_path, env=_deterministic_env())
674
+ logger.info(f"bgzip compression completed successfully for {out_path}")
675
+ return str(out_path)