PedNet 2.0.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.
pednet/genotypes.py ADDED
@@ -0,0 +1,253 @@
1
+ """Reading candidate variants' genotypes from a VCF file or a table."""
2
+ import csv
3
+ import gzip
4
+ import io
5
+ from collections import Counter
6
+ from typing import Dict, Iterable, List, Optional
7
+
8
+ from .segregation import Variant
9
+
10
+
11
+ def _open_text(path):
12
+ """A plain or gzipped text file, read as UTF-8 with or without the byte-order mark Excel writes"""
13
+ with open(path, 'rb') as handle:
14
+ magic = handle.read(2)
15
+ if magic == b'\x1f\x8b':
16
+ return io.TextIOWrapper(gzip.open(path), encoding='utf-8-sig')
17
+ return open(path, encoding='utf-8-sig')
18
+
19
+
20
+ def _unique_ids(variants: List[Variant]) -> List[Variant]:
21
+ """
22
+ Makes every variant's ID its own. An ID shared by several variants, as when bcftools norm -m- splits a site with
23
+ two alternate alleles and keeps its rsID on both, has the variant's place added (rs9 1:100:A>T), or failing that
24
+ a number
25
+ """
26
+ counts = Counter(v.id for v in variants)
27
+ seen: Counter = Counter()
28
+ for v in variants:
29
+ if counts[v.id] > 1:
30
+ if v.chrom is not None and v.pos is not None:
31
+ v.id = f'{v.id} {v.chrom}:{v.pos}:{v.ref}>{v.alt}'
32
+ else:
33
+ seen[v.id] += 1
34
+ v.id = f'{v.id} ({seen[v.id]})'
35
+ if len({v.id for v in variants}) < len(variants):
36
+ seen = Counter()
37
+ for v in variants:
38
+ seen[v.id] += 1
39
+ if seen[v.id] > 1:
40
+ v.id = f'{v.id} ({seen[v.id]})'
41
+ return variants
42
+
43
+
44
+ def _copies(call: str, allele: int = 1) -> Optional[int]:
45
+ """The number of copies of an allele in a genotype call such as 0/1, 1|1, 1 or ./. (None if missing)"""
46
+ call = call.strip()
47
+ if call in ('', '.', 'NA', 'nan'):
48
+ return None
49
+ alleles = call.replace('|', '/').split('/')
50
+ if any(a in ('.', '') for a in alleles):
51
+ return None
52
+ try:
53
+ return sum(int(a) == allele for a in alleles)
54
+ except ValueError:
55
+ return None
56
+
57
+
58
+ _COUNTS = {'0': 0, '1': 1, '2': 2, '0.0': 0, '1.0': 1, '2.0': 2}
59
+
60
+
61
+ def _table_genotype(value: str, where: str = '') -> Optional[int]:
62
+ """
63
+ A genotype in a table: a count of alternate alleles (0, 1 or 2, or 0.0, 1.0 or 2.0) or a call such as 0/1 or 1|1,
64
+ with '', '.', 'NA' and ./. for no call. Anything else is an error, named by where.
65
+ """
66
+ value = str(value).strip()
67
+ if value in ('', '.', 'NA', 'nan', './.', '.|.', '-'):
68
+ return None
69
+ if value in _COUNTS:
70
+ return _COUNTS[value]
71
+ alleles = value.replace('|', '/').split('/')
72
+ if len(alleles) == 2 and all(a.isdigit() for a in alleles):
73
+ return sum(a != '0' for a in alleles)
74
+ if len(alleles) == 2 and all(a == '.' or a.isdigit() for a in alleles):
75
+ return None
76
+ raise ValueError(f'{where}{" " if where else ""}has the genotype {value!r}, which isn\'t 0, 1 or 2 copies or a '
77
+ 'call such as 0/1')
78
+
79
+
80
+ def _frequency(value: Optional[str], where: str) -> Optional[float]:
81
+ if value is None or value.strip().lower() in ('', '.', 'na', 'nan', '-'):
82
+ return None
83
+ try:
84
+ frequency = float(value)
85
+ except ValueError:
86
+ raise ValueError(f'{where} has the allele frequency {value!r}, which isn\'t a number') from None
87
+ if not 0 <= frequency <= 1:
88
+ raise ValueError(f'{where} has the allele frequency {value!r}, which isn\'t between 0 and 1')
89
+ return frequency
90
+
91
+
92
+ # INFO fields that may name a variant's gene, in the order they are looked for: those of VEP, SnpEff and bcftools csq,
93
+ # then single fields, as from ANNOVAR or dbSNP
94
+ GENE_KEYS = ('CSQ', 'ANN', 'BCSQ', 'GENE', 'SYMBOL', 'Gene.refGene', 'Gene.ensGene', 'GENEINFO')
95
+
96
+
97
+ def read_vcf(path, samples: Optional[Iterable[str]] = None, frequency_key: str = 'AF',
98
+ passed_only: bool = False, gene_key: Optional[str] = None) -> List[Variant]:
99
+ """
100
+ Reads candidate variants from a VCF file (plain or gzipped). Each alternate allele of a site becomes a variant
101
+ of its own, with each sample's number of copies of it from the GT field. The allele frequency is taken from
102
+ the INFO field frequency_key (AF by default; for example gnomAD's AF_joint, if the VCF was annotated with it).
103
+
104
+ samples: keep only these samples (for example the people in a pedigree)
105
+ passed_only: keep only sites whose FILTER is PASS or '.'
106
+ gene_key: the INFO field naming the gene, which pairs variants up as compound heterozygotes. By default the
107
+ first of GENE_KEYS present: VEP's CSQ (its SYMBOL), SnpEff's ANN, bcftools csq's BCSQ, or a field such as
108
+ GENE, Gene.refGene (ANNOVAR) or GENEINFO (dbSNP)
109
+ """
110
+ wanted = set(samples) if samples is not None else None
111
+ variants: List[Variant] = []
112
+ names: List[str] = []
113
+ csq_fields: List[str] = []
114
+ with _open_text(path) as handle:
115
+ for line in handle:
116
+ if line.startswith('##'):
117
+ if line.startswith('##INFO=<ID=CSQ,') and 'Format:' in line:
118
+ csq_fields = line.split('Format:', 1)[1].strip().rstrip('">').strip().split('|')
119
+ continue
120
+ if line.startswith('#'):
121
+ names = line.rstrip('\n\r').split('\t')[9:]
122
+ continue
123
+ fields = line.rstrip('\n\r').split('\t')
124
+ if len(fields) < 8:
125
+ continue
126
+ chrom, pos, vid, ref, alts, _, filt, info = fields[:8]
127
+ if passed_only and filt not in ('PASS', '.'):
128
+ continue
129
+ fmt = fields[8].split(':') if len(fields) > 8 else []
130
+ gt = fmt.index('GT') if 'GT' in fmt else None
131
+ frequencies = _info_values(info, frequency_key)
132
+ items = _info_items(info)
133
+ alt_list = alts.split(',')
134
+ for a, alt in enumerate(alt_list, start=1):
135
+ if alt in ('.', '*'):
136
+ continue
137
+ genotypes: Dict[str, Optional[int]] = {}
138
+ if gt is not None:
139
+ for name, sample in zip(names, fields[9:]):
140
+ if wanted is not None and name not in wanted:
141
+ continue
142
+ parts = sample.split(':')
143
+ genotypes[name] = _copies(parts[gt], a) if gt < len(parts) else None
144
+ if vid not in ('.', '') and len(alt_list) == 1:
145
+ identifier = vid
146
+ else:
147
+ identifier = f'{chrom}:{pos}:{ref}>{alt}'
148
+ frequency = frequencies[a - 1] if len(frequencies) >= a else None
149
+ gene = _gene(items, alt, len(alt_list), gene_key, csq_fields)
150
+ variants.append(Variant(identifier, genotypes, chrom, int(pos), ref, alt, frequency, gene))
151
+ return _unique_ids(variants)
152
+
153
+
154
+ def vcf_samples(path) -> List[str]:
155
+ """The names of a VCF file's samples, from its header"""
156
+ with _open_text(path) as handle:
157
+ for line in handle:
158
+ if line.startswith('#CHROM'):
159
+ return line.rstrip('\n\r').split('\t')[9:]
160
+ if not line.startswith('#'):
161
+ break
162
+ return []
163
+
164
+
165
+ def _info_items(info: str) -> Dict[str, str]:
166
+ items = {}
167
+ for item in info.split(';'):
168
+ key, _, value = item.partition('=')
169
+ items.setdefault(key, value)
170
+ return items
171
+
172
+
173
+ def _gene(items: Dict[str, str], alt: str, n_alts: int, key: Optional[str], csq_fields: List[str]) -> Optional[str]:
174
+ """The gene an alternate allele is in, from the INFO field key (or the first of GENE_KEYS present)"""
175
+ for k in ([key] if key else GENE_KEYS):
176
+ value = items.get(k)
177
+ if not value or value == '.':
178
+ continue
179
+ if k in ('CSQ', 'ANN', 'BCSQ'):
180
+ if k == 'CSQ':
181
+ position = csq_fields.index('SYMBOL') if 'SYMBOL' in csq_fields else 3
182
+ else:
183
+ position = 3 if k == 'ANN' else 1
184
+ matched, others = None, None
185
+ for annotation in value.split(','):
186
+ parts = annotation.split('|')
187
+ if len(parts) <= position or parts[position] in ('', '.'):
188
+ continue
189
+ # an annotation of this allele (ANN and CSQ start with it), or else any
190
+ if k != 'BCSQ' and n_alts > 1 and parts[0] == alt:
191
+ matched = matched or parts[position]
192
+ others = others or parts[position]
193
+ gene = matched or others
194
+ elif k == 'GENEINFO':
195
+ gene = value.split('|')[0].split(':')[0]
196
+ else:
197
+ gene = value.replace('\\x3b', ',').replace('\\x3B', ',').split(',')[0]
198
+ if gene and gene != '.':
199
+ return gene
200
+ return None
201
+
202
+
203
+ def _info_values(info: str, key: str) -> List[Optional[float]]:
204
+ for item in info.split(';'):
205
+ if item.startswith(key + '='):
206
+ out = []
207
+ for value in item[len(key) + 1:].split(','):
208
+ try:
209
+ out.append(float(value))
210
+ except ValueError:
211
+ out.append(None)
212
+ return out
213
+ return []
214
+
215
+
216
+ def read_genotype_table(path, delimiter: Optional[str] = None) -> List[Variant]:
217
+ """
218
+ Reads candidate variants from a table with one row per variant: a column variant (or id) naming it, optional
219
+ columns chrom, pos, ref, alt, af (its allele frequency) and gene, and a column for each person, holding the number
220
+ of copies of the alternate allele (0, 1 or 2) or a call such as 0/1. Empty cells and ./. mean not genotyped.
221
+ The columns are separated by tabs, unless the file ends in .csv or delimiter says otherwise.
222
+ """
223
+ if delimiter is None:
224
+ delimiter = ',' if str(path).lower().endswith('.csv') else '\t'
225
+ known = {'variant', 'id', 'chrom', 'chr', 'chromosome', 'pos', 'position', 'ref', 'alt', 'af', 'allele_frequency',
226
+ 'gene', 'symbol'}
227
+ variants = []
228
+ with _open_text(path) as handle:
229
+ reader = csv.DictReader(handle, delimiter=delimiter)
230
+ if reader.fieldnames is None:
231
+ return []
232
+ lower = {name: name.strip().lower() for name in reader.fieldnames}
233
+ people = [name for name in reader.fieldnames if lower[name] not in known]
234
+ def column(row, *keys):
235
+ for name in reader.fieldnames:
236
+ if lower[name] in keys and row.get(name) not in (None, ''):
237
+ return row[name].strip()
238
+ return None
239
+ for number, row in enumerate(reader, start=1):
240
+ identifier = column(row, 'variant', 'id') or f'variant {number}'
241
+ where = f'Row {number} ({identifier})'
242
+ pos = column(row, 'pos', 'position')
243
+ try:
244
+ position = int(pos) if pos else None
245
+ except ValueError:
246
+ raise ValueError(f'{where} has the position {pos!r}, which isn\'t a whole number') from None
247
+ genotypes = {p.strip(): _table_genotype(row[p] if row[p] is not None else '', f'{where}, column {p},')
248
+ for p in people}
249
+ variants.append(Variant(identifier, genotypes, column(row, 'chrom', 'chr', 'chromosome'), position,
250
+ column(row, 'ref'), column(row, 'alt'),
251
+ _frequency(column(row, 'af', 'allele_frequency'), where),
252
+ column(row, 'gene', 'symbol')))
253
+ return _unique_ids(variants)