Coverage for rest_api/data_entry/annotation_import.py: 20%

102 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-10 03:22 +0000

1from dataclasses import dataclass 

2import gzip 

3import re 

4 

5from django.db.models import Q 

6 

7from rest_api.models import AnnotationType 

8from rest_api.models import NucleotideMutation 

9from sonar_backend.settings import LOGGER 

10 

11 

12@dataclass 

13class VCFRaw: 

14 chrom: str # mutation__replicon__accession 

15 pos: int # mutation__start 

16 id: str 

17 ref: str # mutation__ref 

18 alt: str | None 

19 qual: str 

20 filter: str 

21 info: str 

22 format: str 

23 

24 

25@dataclass 

26class VCFInfoANNRaw: 

27 # Functional annotations 

28 allele: str 

29 annotation: str 

30 annotation_impact: str 

31 gene_name: str 

32 gene_id: str 

33 feature_type: str 

34 feature_id: str 

35 transcript_bio_type: str 

36 rank: str 

37 hgvs_c: str 

38 hgvs_p: str 

39 c_dna_pos_length: str 

40 cds_pos_length: str 

41 aa_pos_length: str 

42 distance: str 

43 errors_warnings_info: str 

44 

45 

46@dataclass 

47class MutationLookupToAnnotations: 

48 start: str 

49 end: str 

50 ref: str 

51 alt: str 

52 replicon__accession: str 

53 annotations: list[VCFInfoANNRaw] 

54 

55 

56class AnnotationImport: 

57 def __init__(self, path: str): 

58 self.vcf_file_path = path 

59 self.raw_lines = [line for line in self._import_vcf()] 

60 self.mutation_lookups_to_annotations = self.convert_lines() 

61 self.annotation_q_obj = Q() 

62 

63 def _import_vcf(self): 

64 # Open compressed or uncompressed VCF based on file extension 

65 open_func = gzip.open if self.vcf_file_path.endswith(".gz") else open 

66 with open_func(self.vcf_file_path, "rt") as handle: # 'rt' for text mode 

67 for line in handle: 

68 if line.startswith(("#", "##")): 

69 continue 

70 line = line.strip("\r\n").split("\t") 

71 vcf_raw = VCFRaw( 

72 chrom=line.pop(0), 

73 pos=int(line.pop(0)), 

74 id=line.pop(0), 

75 ref=line.pop(0), 

76 alt=None if (alt := line.pop(0)) == "." else alt, 

77 qual=line.pop(0), 

78 filter=line.pop(0), 

79 info=line.pop(0), 

80 format=line.pop(0), 

81 ) 

82 yield vcf_raw 

83 

84 def convert_lines(self) -> list[MutationLookupToAnnotations]: 

85 mutation_lookups_to_annotations = [] 

86 for line in self.raw_lines: 

87 annotations = self._parse_line_info(line.info) 

88 allele_to_annotations = {} 

89 for annotation in annotations: 

90 if annotation.allele not in allele_to_annotations: 

91 allele_to_annotations[annotation.allele] = [] 

92 allele_to_annotations[annotation.allele].append(annotation) 

93 for alt in line.alt.split(","): 

94 if alt not in allele_to_annotations: 

95 continue 

96 mutation_lookup_to_annotations = MutationLookupToAnnotations( 

97 start=line.pos 

98 - 1, # snp (we deduct by one because our database use 0-based) 

99 end=int(line.pos), 

100 ref=line.ref, 

101 alt=alt, 

102 replicon__accession=line.chrom, 

103 annotations=allele_to_annotations[alt], 

104 ) 

105 if len(alt) < len(mutation_lookup_to_annotations.ref): 

106 # deletion and alt not null 

107 # because in vcf it always show the positon before deletion 

108 # for example; MN908947.3 506 . CATGGTCATGTTATGGTTG C 

109 mutation_lookup_to_annotations.start += 1 

110 mutation_lookup_to_annotations.end = ( 

111 mutation_lookup_to_annotations.end 

112 + len(mutation_lookup_to_annotations.ref) 

113 - 1 

114 ) 

115 # add None here if we dont want to keep the deletion in ref 

116 # column, however the program frozen once I change to 

117 # mutation_lookup_to_annotations.ref = None 

118 mutation_lookup_to_annotations.ref = "" 

119 mutation_lookup_to_annotations.alt = "" 

120 

121 mutation_lookups_to_annotations.append(mutation_lookup_to_annotations) 

122 return mutation_lookups_to_annotations 

123 

124 def _parse_line_info(self, info: str) -> list[VCFInfoANNRaw]: 

125 """ 

126 Extracts the SNP effect annotation (ANN) substring from the 8th column of a tab-separated SnpEff annotation line. 

127 

128 Parameters: 

129 info (str): The 8th column of a tab-separated SnpEff annotation line. Example: 

130 "ANN=C|upstream_gene_variant|MODIFIER|ORF1a|Gene_265_13467|transcript|ORF1a|protein_coding||c.-217_-213delTCTTG|||||217|WARNING_TRANSCRIPT_NO_STOP_CODON, 

131 C|intergenic_region|MODIFIER|CHR_START-ORF1a|CHR_START-Gene_265_13467|intergenic_region|CHR_START-Gene_265_13467|||n.49_53delTCTTG||||||" 

132 

133 Returns: 

134 list[VCFInfoANNRaw]: list of extracted annotations, different annotations are separated by ',' 

135 """ 

136 for field in info.split(";"): 

137 if field.startswith("ANN="): 

138 ann_field = field.removeprefix("ANN=") 

139 annotations = [] 

140 for annotation in ann_field.split(","): 

141 annotation = annotation.split("|") 

142 try: 

143 annotations.append(VCFInfoANNRaw(*annotation)) 

144 except Exception: 

145 LOGGER.warning( 

146 f"Failed to parse annotation: {annotation}, from file {self.vcf_file_path}" 

147 ) 

148 # Return after finding the first ANN= field. If there are 

149 # multiple, all others will be ignored. 

150 return annotations 

151 return [] 

152 

153 def get_annotation_objs( 

154 self, 

155 ) -> tuple[list[AnnotationType], list[AnnotationType.mutations.through]]: 

156 annotation_objs = [] 

157 q_obj = Q() 

158 # self.mutation_lookups_to_annotations read from vcf file. 

159 for mutation_lookup_to_annotations in self.mutation_lookups_to_annotations: 

160 q_obj |= Q( 

161 start=mutation_lookup_to_annotations.start, 

162 end=mutation_lookup_to_annotations.end, 

163 ref=mutation_lookup_to_annotations.ref, 

164 alt=mutation_lookup_to_annotations.alt, 

165 replicon__accession=mutation_lookup_to_annotations.replicon__accession, 

166 ) 

167 mutations = NucleotideMutation.objects.filter(q_obj).prefetch_related( 

168 "replicon" 

169 ) 

170 annotation_q_obj = Q() 

171 relation_info = {} 

172 for mutation in mutations: 

173 # Problem 1: some samples got error 'StopIteration' 

174 # I think because there are no items in the filtered iterable 

175 # that match the conditions specified by the lambda function 

176 # But How did this can happen?, 

177 # Solution: because there are cds and nt at the same position we should filter only NT 

178 

179 try: 

180 mut_lookup_to_annotation = self.mutation_lookups_to_annotations.pop( 

181 self.mutation_lookups_to_annotations.index( 

182 next( 

183 filter( 

184 lambda x: int(x.start) == int(mutation.start) 

185 and int(x.end) == int(mutation.end) 

186 and x.ref == mutation.ref 

187 and x.alt == mutation.alt 

188 and x.replicon__accession 

189 == mutation.replicon.accession, 

190 self.mutation_lookups_to_annotations, 

191 ) 

192 ) 

193 ) 

194 ) 

195 except (ValueError, StopIteration) as e: 

196 LOGGER.error(f"Error: {e}") 

197 LOGGER.error( 

198 f"Mutation details: start={mutation.start}, end={mutation.end}, ref={mutation.ref}, alt={mutation.alt}, replicon__accession={mutation.replicon.accession}" 

199 ) 

200 raise 

201 

202 # print(mut_lookup_to_annotation) 

203 # Problem 2: We have too many entries in mut_lookup_to_annotation.annotations. 

204 # For example, if we have the mutation MN908947.3 26565 . A ANNNNNNN (insertion with ambiguous lots of Ns), 

205 # snpEff tries to predict the effect for every possible combination: 

206 # insCAAAAAA, insCAAAAAC, insCAAAAAG, insCAAAAAT, ..., insTAAAAAA, ..., insGAAAAAA. 

207 # This can generate a lookup annotation size of 4 (A,T,C,G) to the power of N (position). 

208 # In this case, 4 to the power of 7 equals 16384. 

209 # Currently, we only use ontology (e.g., frameshift_variant) and annotation_impact (e.g., HIGH), 

210 # which means a potentially highly redundant query, hence taking too long to finish the import process (> 10 mins). 

211 

212 # Temporary solution: reduce the redundancy in alleles, annotations, and impacts. 

213 # we skip processing based on alleles, annotations, and impacts 

214 seen = set() 

215 # start to pass each VCFInfoANNRaw 

216 for a in mut_lookup_to_annotation.annotations: 

217 key = (a.allele, a.annotation, a.annotation_impact) 

218 if key in seen: 

219 continue 

220 seen.add(key) 

221 for ontology in a.annotation.split("&"): 

222 annotation_obj = AnnotationType( 

223 seq_ontology=ontology, impact=a.annotation_impact 

224 ) 

225 annotation_q_obj |= Q( 

226 seq_ontology=ontology, impact=a.annotation_impact 

227 ) 

228 if ontology not in relation_info: 

229 relation_info[ontology] = {} 

230 if a.annotation_impact not in relation_info[ontology]: 

231 relation_info[ontology][a.annotation_impact] = [] 

232 relation_info[ontology][a.annotation_impact].append( 

233 { 

234 "mutation": mutation, 

235 } 

236 ) 

237 annotation_objs.append(annotation_obj) 

238 self.annotation_q_obj = annotation_q_obj 

239 self.relation_info = relation_info 

240 return annotation_objs 

241 

242 def get_annotation2mutation_objs(self) -> list[AnnotationType.mutations.through]: 

243 annotations = AnnotationType.objects.filter(self.annotation_q_obj) 

244 mutation2annotation_objs = [] 

245 for annotation in annotations: 

246 for relation in self.relation_info[annotation.seq_ontology][ 

247 annotation.impact 

248 ]: 

249 mutation = relation["mutation"] 

250 mutation2annotation_objs.append( 

251 AnnotationType.mutations.through( 

252 annotationtype_id=annotation.id, 

253 nucleotidemutation_id=mutation.id, 

254 ) 

255 ) 

256 return mutation2annotation_objs