|
| 1 | +#!/usr/bin/env python |
| 2 | + |
| 3 | +import sys, os, re, csv, pysam, json, binascii, math, argparse |
| 4 | +import sqlite3 |
| 5 | +import pandas as pd |
| 6 | +import numpy as np |
| 7 | +from time import gmtime, strftime |
| 8 | +from natsort import natsort_keygen |
| 9 | +from pathlib import Path |
| 10 | + |
| 11 | +__version__ = '1.0.0' |
| 12 | + |
| 13 | +def sanitize_for_json(obj): |
| 14 | + """Recursively replace inf and nan with None.""" |
| 15 | + if isinstance(obj, dict): |
| 16 | + return {k: sanitize_for_json(v) for k, v in obj.items()} |
| 17 | + elif isinstance(obj, list): |
| 18 | + return [sanitize_for_json(v) for v in obj] |
| 19 | + elif isinstance(obj, float): |
| 20 | + if math.isinf(obj) or math.isnan(obj): |
| 21 | + return None # Will become null in JSON |
| 22 | + return obj |
| 23 | + |
| 24 | +def parse_dragen_metrics_file(metrics_file): |
| 25 | + df = pd.read_csv(metrics_file,sep=',',names=['category','readgroup','metric','value','percent']) |
| 26 | + # add sequential index as column for sorting later |
| 27 | + df.insert(0,'index',range(len(df))) |
| 28 | + |
| 29 | + df = df[df['readgroup'].isna()].drop(columns='readgroup') |
| 30 | + df['metric'] = df['category'] + ': ' + df['metric'] |
| 31 | + dfpct = df[df['percent'].notna()].copy() |
| 32 | + df = df.drop(columns='percent') |
| 33 | + dfpct['metric'] = dfpct['metric'].apply(lambda x: x + ' (%)') |
| 34 | + dfpct['value'] = dfpct['percent'] |
| 35 | + dfpct = dfpct.drop(columns='percent') |
| 36 | + df = pd.concat([df,dfpct]) |
| 37 | + |
| 38 | + # change metric with format [\d+x: inf) to >\d+x |
| 39 | + df.loc[df['metric'].str.contains(r'\[\s*\d+x: inf\)'), 'metric'] = df[df['metric'].str.contains(r'\[\s*\d+x: inf\)')]['metric'].str.replace(r'\[\s*(\d+)x: inf\)', r'>\1x', regex=True) |
| 40 | + |
| 41 | + # change 'PCT of' to "Percent of" |
| 42 | + df.loc[df['metric'].str.contains('PCT of'), 'metric'] = df[df['metric'].str.contains('PCT of')]['metric'].str.replace('PCT of', 'Percent of', regex=False) |
| 43 | + |
| 44 | + # if metric contains "Number of" and contains "(%)" replace "Number of" with "Percent" |
| 45 | + df.loc[df['metric'].str.contains('Number of') & df['metric'].str.contains('\(%\)'), 'metric'] = df[df['metric'].str.contains('Number of') & df['metric'].str.contains('\(%\)')]['metric'].str.replace('Number of', 'Percent', regex=False) |
| 46 | + |
| 47 | + # convert value to numeric where possible |
| 48 | + df['value'] = pd.to_numeric(df['value'], errors='coerce') |
| 49 | + |
| 50 | + # Duplicate metrics for large values with new units, keeping originals |
| 51 | + df_g = df[df['value'] > 5e9].copy() |
| 52 | + df_g['metric'] = df_g['metric'] + ' (G)' |
| 53 | + df_g['value'] = (df_g['value'] / 1e9).round(1) |
| 54 | + df_m = df[(df['value'] > 5e6) & (df['value'] <= 5e9)].copy() |
| 55 | + df_m['metric'] = df_m['metric'] + ' (M)' |
| 56 | + df_m['value'] = (df_m['value'] / 1e6).round(1) |
| 57 | + df = pd.concat([df, df_g, df_m], ignore_index=True) |
| 58 | + |
| 59 | + # order by index column |
| 60 | + df = df.sort_values(by=['index','metric']).drop(columns='index').reset_index(drop=True) |
| 61 | + |
| 62 | + return df[['category','metric','value']] |
| 63 | + |
| 64 | +def parse_coverage_report(coverage_report_file,aggregate_keys=['gene','biomarker']): |
| 65 | + df = pd.read_csv(coverage_report_file, header=None, sep="\t") |
| 66 | + df.columns = df.iloc[0] |
| 67 | + df.columns = df.columns.str.replace('^#', '', regex=True) |
| 68 | + df = df.drop(df.index[0]) |
| 69 | + df.fillna(0, inplace=True) |
| 70 | + df['start'] = df['start'].astype(int) |
| 71 | + df['end'] = df['end'].astype(int) |
| 72 | + # Convert columns 5 onward to float in place to avoid dtype conflict |
| 73 | + for col in df.columns[5:]: |
| 74 | + df[col] = pd.to_numeric(df[col], errors='coerce') |
| 75 | + |
| 76 | + df.insert(4,'region',df['info'].str.split('|',expand=True).loc[:,1]) # parse region from info column |
| 77 | + df.insert(5,'region_type',df['info'].str.split('|',expand=True).loc[:,0]) # parse region from info |
| 78 | + df.reset_index(drop=True, inplace=True) |
| 79 | + |
| 80 | + # if df['region'] contains any of the aggregate keys |
| 81 | + if aggregate_keys is not None and len(df['region_type'].isin(aggregate_keys)) > 0: |
| 82 | + for key in aggregate_keys: |
| 83 | + sdf = df[df['region_type']==key].copy() |
| 84 | + sdf['length'] = sdf['end']-sdf['start']+1 |
| 85 | + pct_cov_columns = [x for x in sdf.columns if "pct_above" in x] |
| 86 | + sdf[pct_cov_columns] = sdf[pct_cov_columns].astype(float).div(100).mul(sdf['length'], axis=0) |
| 87 | + aggregate_funcs = {key: 'sum' for key in pct_cov_columns + ['total_cvg','length']} |
| 88 | + aggregate_funcs['min_cvg'] = 'min' |
| 89 | + aggregate_funcs['max_cvg'] = 'max' |
| 90 | + aggregate_funcs['start'] = 'min' |
| 91 | + aggregate_funcs['end'] = 'max' |
| 92 | + sdf = sdf.groupby(['gene']).agg(aggregate_funcs).reset_index() |
| 93 | + sdf[pct_cov_columns] = sdf[pct_cov_columns].div(sdf['length'], axis=0).mul(100) |
| 94 | + sdf[pct_cov_columns] = sdf[pct_cov_columns].round(1) |
| 95 | + sdf['mean_cvg'] = sdf['total_cvg'] / sdf['length'] |
| 96 | + |
| 97 | + sdf['region'] = key |
| 98 | + sdf['region_type'] = key |
| 99 | + |
| 100 | + sdf = sdf.drop(columns='length') |
| 101 | + sdf = sdf.reindex(columns=df.columns) |
| 102 | + |
| 103 | + df = pd.concat([df,sdf],axis=0) |
| 104 | + |
| 105 | + df[['mean_cvg','min_cvg','max_cvg']] = df[['mean_cvg','min_cvg','max_cvg']].astype(int) |
| 106 | + |
| 107 | + return df |
| 108 | + |
| 109 | +def parse_wgs_histogram(wgs_hist_file, coverage_depths=[1,5,10,20,30,50,100,200,500,1000]): |
| 110 | + dtype = {'Depth': str, 'Overall': str} |
| 111 | + histDf = (pd.read_csv( |
| 112 | + wgs_hist_file, |
| 113 | + sep=",", |
| 114 | + dtype=dtype |
| 115 | + ) |
| 116 | + .iloc[:-1] # Omits the last row (replaces the "2000+" string filter) |
| 117 | + .assign( |
| 118 | + Depth=lambda x: pd.to_numeric(x['Depth'], errors='coerce') + 1, |
| 119 | + Overall=lambda x: pd.to_numeric(x['Overall'], errors='coerce') |
| 120 | + ) |
| 121 | + .assign(Fraction=lambda x: 100 - (x['Overall'].cumsum() / x['Overall'].sum()) * 100) |
| 122 | + .query('Depth in @coverage_depths') |
| 123 | + .sort_values('Depth', ascending=False) |
| 124 | + ) |
| 125 | + |
| 126 | + histDf['category'] = "COVERAGE SUMMARY" |
| 127 | + histDf['metric'] = histDf.apply(lambda x: f"COVERAGE SUMMARY: Percent of genome with coverage >{x['Depth']}x",axis=1) |
| 128 | + histDf['value'] = histDf.apply(lambda x: round(x['Fraction'], 1), axis=1) |
| 129 | + |
| 130 | + return histDf[['category','metric','value']] |
| 131 | + |
| 132 | +# parse haplotect loci file or dataframe into per-site genotypes |
| 133 | +def pack_haplotect(haplotectlocidf=None,haplotect_file=None): |
| 134 | + df = pd.DataFrame() |
| 135 | + if haplotectlocidf is not None: |
| 136 | + df = haplotectlocidf[['chr','snp1','snp2','total_reads','haplotype_counts']].copy() |
| 137 | + elif haplotect_file is not None and Path(haplotect_file).is_file(): |
| 138 | + haplotectlocidf = pd.read_csv(haplotect_file,sep='\t') |
| 139 | + haplotectlocidf.columns = haplotectlocidf.columns.str.replace('#', '') |
| 140 | + df = haplotectlocidf.iloc[:-2] |
| 141 | + else: |
| 142 | + return None |
| 143 | + |
| 144 | + # parse haplotype strings |
| 145 | + df['sites'] = df['haplotype_counts'].str.strip(';') |
| 146 | + df['sites'] = df['sites'].str.split(';') |
| 147 | + df = df.explode('sites') |
| 148 | + df[['hap','counts']] = df['sites'].str.split(':',expand=True) |
| 149 | + df = df.dropna(subset=['counts']) |
| 150 | + df['counts'] = df['counts'].astype(int) |
| 151 | + # filter to get only homozygous and major heterozygous haplotypes (avoid contaminating haps) |
| 152 | + df['fraction'] = df['counts']/df['total_reads'].astype(int) |
| 153 | + df = df[df['fraction']>.40] # 40% is arbitrary but should be very specific |
| 154 | + # parse haplotypes into sites and make genotypes |
| 155 | + df[['allele1','allele2']] = df['hap'].apply(lambda x: pd.Series(list(x))) |
| 156 | + df1 = df[['chr','snp1','allele1']] |
| 157 | + df1.columns = ['chr','pos','gt'] |
| 158 | + df2 = df[['chr','snp2','allele2']] |
| 159 | + df2.columns = ['chr','pos','gt'] |
| 160 | + df = pd.concat([df1,df2],ignore_index=True).drop_duplicates() |
| 161 | + df = df.groupby(['chr','pos'])[['gt']].agg(list).reset_index() |
| 162 | + df['gt'] = df['gt'].apply(lambda x: "".join(sorted(x)) if len(x)==2 else "".join(x+x)) |
| 163 | + df = df.sort_values( |
| 164 | + by=['chr','pos'], |
| 165 | + key=natsort_keygen()) |
| 166 | + |
| 167 | + # pack into string chr1,pos1,gt1|chr2,pos2,gt2|... |
| 168 | + genotypes = '|'.join(df.apply(lambda row: f"{row['chr']},{row['pos']},{row['gt']}", axis=1)) |
| 169 | + |
| 170 | + return genotypes |
| 171 | + |
| 172 | +# |
| 173 | +# Script |
| 174 | +# |
| 175 | + |
| 176 | +def main(): |
| 177 | + |
| 178 | + parser = argparse.ArgumentParser(description='Collect Dragen QC and coverage metrics.') |
| 179 | + parser.add_argument('-c','--coverage-report',help='Mopath coverage report file') |
| 180 | + parser.add_argument('-m','--mapping-metrics',help='Dragen mapping metrics file') |
| 181 | + parser.add_argument('-n','--cnv-metrics',help='Dragen CNV metrics file') |
| 182 | + parser.add_argument('-u','--umi-metrics',help='Dragen UMI metrics file') |
| 183 | + parser.add_argument('-w','--wgs-coverage-metrics',help='Dragen WGS metrics file') |
| 184 | + parser.add_argument('-f','--wgs-fine-hist',help='Dragen WGS fine histogram file') |
| 185 | + parser.add_argument('-t','--haplotect',help='Haplotect file') |
| 186 | + |
| 187 | + parser.add_argument('-l','--coverage-qc-levels',default='10,20,40,60,80,100,150,200',help='Genome-wide coverage levels to collect for WGS assays.') |
| 188 | + parser.add_argument('-s','--coverage-summary-keys',default='gene,biomarker',help='Strings to use for target coverage summarization. [default: gene,biomarker]') |
| 189 | + |
| 190 | + # outfile name |
| 191 | + parser.add_argument('-o','--outfile',help='Output file name.',default=None) |
| 192 | + |
| 193 | + parser.add_argument('-v', '--version', action='version', version='%(prog)s: ' + __version__) |
| 194 | + |
| 195 | + args = parser.parse_args() |
| 196 | + |
| 197 | + # Exit and print usage if no arguments provided |
| 198 | + if len(sys.argv) == 1: |
| 199 | + parser.print_help() |
| 200 | + sys.exit(1) |
| 201 | + |
| 202 | + qcDf = pd.DataFrame(columns=['category','metric','value']) |
| 203 | + covDf = pd.DataFrame(columns=['gene','region','start','end','region_type','mean_cvg','min_cvg','max_cvg']) |
| 204 | + |
| 205 | + if args.mapping_metrics is not None and Path(args.mapping_metrics).is_file(): |
| 206 | + mapping_df = parse_dragen_metrics_file(args.mapping_metrics) |
| 207 | + if not qcDf.empty and not mapping_df.empty: |
| 208 | + qcDf = pd.concat([qcDf, mapping_df]) |
| 209 | + elif not mapping_df.empty: |
| 210 | + qcDf = mapping_df |
| 211 | + |
| 212 | + if args.cnv_metrics is not None and Path(args.cnv_metrics).is_file(): |
| 213 | + cnv_df = parse_dragen_metrics_file(args.cnv_metrics) |
| 214 | + # |
| 215 | + # Chromosome number and ploidy |
| 216 | + # |
| 217 | + ploidy = float(cnv_df[cnv_df["metric"].str.contains("Overall ploidy")]["value"].tolist()[0]) |
| 218 | + dragen_chromosome_number = int(round(ploidy * 23, 0)) |
| 219 | + chromosomes = dragen_chromosome_number if abs(dragen_chromosome_number - 46) > 2 else max(46, dragen_chromosome_number) |
| 220 | + cnv_df = pd.concat([cnv_df, pd.DataFrame([{'category': 'CNV SUMMARY', 'metric': 'CNV SUMMARY: Chromosome number', 'value': chromosomes}])], ignore_index=True) |
| 221 | + |
| 222 | + if not qcDf.empty and not cnv_df.empty: |
| 223 | + qcDf = pd.concat([qcDf, cnv_df]) |
| 224 | + elif not cnv_df.empty: |
| 225 | + qcDf = cnv_df |
| 226 | + |
| 227 | + if args.umi_metrics is not None and Path(args.umi_metrics).is_file(): |
| 228 | + umiDf = parse_dragen_metrics_file(args.umi_metrics) |
| 229 | + |
| 230 | + consensusReads = round(umiDf.loc[umiDf.metric=='UMI STATISTICS: Consensus pairs emitted','value'].astype(int).tolist()[0] * 2 / umiDf.loc[umiDf.metric=='UMI STATISTICS: Number of reads','value'].astype(int).tolist()[0] * 100,1) |
| 231 | + duplicateReads = round(100-consensusReads,1) |
| 232 | + |
| 233 | + umiDf = pd.concat([umiDf,pd.DataFrame.from_dict({0:['UMI STATISTICS','UMI STATISTICS: Consensus reads (%)',consensusReads,0], |
| 234 | + 1:['UMI STATISTICS','UMI STATISTICS: Duplicate reads (%)',duplicateReads,1]},orient='index',columns=['category','metric','value','qcmetric'])],axis=0) |
| 235 | + |
| 236 | + # Custom on-target read calculation |
| 237 | + # Enrichment Rate we use for UMI collapsed data is from the “On target number of reads ” from umi_metrics.csv divided by “Mapped reads” from mapping_metrics.csv. As a first step to troubleshooting on-target rate, it would great to confirm if you are reporting this value. |
| 238 | + |
| 239 | + ontargetReads = umiDf.loc[umiDf.metric=='UMI STATISTICS: On target number of reads','value'].astype(int).tolist()[0] |
| 240 | + mappedReads = qcDf.loc[qcDf.metric=='MAPPING/ALIGNING SUMMARY: Mapped reads','value'].astype(int).tolist()[0] |
| 241 | + umiOnTargetRate = round(ontargetReads / mappedReads * 100,1) |
| 242 | + |
| 243 | + umiDf = pd.concat([umiDf,pd.DataFrame.from_dict({0:['UMI STATISTICS','UMI STATISTICS: On-target rate (%)',umiOnTargetRate,0]},orient='index',columns=['category','metric','value'])],axis=0) |
| 244 | + |
| 245 | + qcDf = pd.concat([qcDf,umiDf]) |
| 246 | + |
| 247 | + if args.wgs_coverage_metrics is not None and Path(args.wgs_coverage_metrics).is_file(): |
| 248 | + qcDf = pd.concat([qcDf,parse_dragen_metrics_file(args.wgs_coverage_metrics)]) |
| 249 | + |
| 250 | + if args.wgs_fine_hist is not None and Path(args.wgs_fine_hist).is_file(): |
| 251 | + qcDf = pd.concat([qcDf,parse_wgs_histogram(args.wgs_fine_hist, coverage_depths=[int(x) for x in args.coverage_qc_levels.split(',')])]) |
| 252 | + |
| 253 | + if args.coverage_report is not None and Path(args.coverage_report).is_file(): |
| 254 | + covDf = parse_coverage_report(args.coverage_report, aggregate_keys=args.coverage_summary_keys.split(',') if args.coverage_summary_keys else None) |
| 255 | + |
| 256 | + # Calculate coverage summary for assay targets |
| 257 | + coverageLevelLabels = covDf.columns[13:].tolist() |
| 258 | + covDf['bases'] = covDf['end'] - covDf['start'] + 1 |
| 259 | + assayCov = round(covDf.apply(lambda x: x[coverageLevelLabels] / 100 * x['bases'],axis=1).sum() / covDf['bases'].sum() * 100,1) |
| 260 | + assayCov.index = assayCov.index.str.replace("pct_above_","COVERAGE SUMMARY: Percent of assay with coverage >") + 'x' |
| 261 | + assayCovDf = assayCov.to_frame(name='value').reset_index().rename(columns={0:'metric'}) |
| 262 | + assayCovDf['category'] = 'COVERAGE SUMMARY' |
| 263 | + qcDf = pd.concat([qcDf,assayCovDf.reindex(columns=qcDf.columns)]) |
| 264 | + |
| 265 | + if args.haplotect is not None and Path(args.haplotect).is_file(): |
| 266 | + haplotectlocidf = pd.read_csv(args.haplotect,sep='\t') |
| 267 | + haplotectlocidf.columns = haplotectlocidf.columns.str.replace('#', '') |
| 268 | + haplotectdf = pd.DataFrame([haplotectlocidf.iloc[-1,:-2].tolist()],columns=haplotectlocidf.iloc[-2,:-2].tolist()) |
| 269 | + haplotectdf.columns = haplotectdf.columns.str.replace('#', '') |
| 270 | + haplotectdf['informative_snppairs'] = haplotectdf['informative_snppairs'].astype(int) |
| 271 | + haplotectdf['mle_estimate'] = haplotectdf['mle_estimate'].astype(float) |
| 272 | + haplotectdf['contamination_fraction'] = haplotectdf['contamination_fraction'].fillna(0) |
| 273 | + haplotectdf['contamination_fraction'] = haplotectdf['contamination_fraction'].astype(float) |
| 274 | + |
| 275 | + haplotectlocidf = haplotectlocidf.iloc[:-2] |
| 276 | + # make distance, total_reads, haplotype_counts, contamination_fraction columns numeric |
| 277 | + cols_to_numeric = ['distance','total_reads','contamination_fraction'] |
| 278 | + for col in cols_to_numeric: |
| 279 | + haplotectlocidf[col] = pd.to_numeric(haplotectlocidf[col], errors='coerce') |
| 280 | + |
| 281 | + haplotectdf = haplotectdf.transpose().reset_index().drop(index=0) |
| 282 | + haplotectdf.columns = ['metric','value'] |
| 283 | + haplotectdf['category'] = 'HAPLOTECT' |
| 284 | + haplotectdf['metric'] = haplotectdf['metric'].apply(lambda v: f'HAPLOTECT: {v}') |
| 285 | + # add haplotect genotypes |
| 286 | + haplotectdf = pd.concat([ |
| 287 | + haplotectdf, |
| 288 | + pd.DataFrame.from_dict( |
| 289 | + {0: ['HAPLOTECT', 'HAPLOTECT: Genotypes', pack_haplotect(haplotectlocidf=haplotectlocidf)]}, |
| 290 | + orient='index', |
| 291 | + columns=['category', 'metric', 'value'] |
| 292 | + ) |
| 293 | + ], axis=0) |
| 294 | + |
| 295 | + qcDf = pd.concat([qcDf,haplotectdf.reindex(columns=qcDf.columns)]) |
| 296 | + |
| 297 | + qcDf['qcmetric'] = 0 |
| 298 | + qcDf['qcflag'] = 0 |
| 299 | + |
| 300 | + ######################## |
| 301 | + # |
| 302 | + # Start report |
| 303 | + # |
| 304 | + ######################## |
| 305 | + |
| 306 | + if qcDf.empty and covDf.empty: |
| 307 | + print("No QC or coverage data found to report.",file=sys.stderr) |
| 308 | + sys.exit(0) |
| 309 | + |
| 310 | + # make dict for report and redirect output for text report |
| 311 | + jsonout = {} |
| 312 | + |
| 313 | + jsonout['ASSAY'] = qcDf.to_dict('split') |
| 314 | + jsonout['ASSAY'].pop('index', None) |
| 315 | + |
| 316 | + coverageLevelLabels = [x for x in covDf.columns if "pct_above" in x] |
| 317 | + |
| 318 | + xdf = covDf[(covDf['region_type']=='hotspot')][['gene','region','info','mean_cvg','min_cvg','max_cvg'] + coverageLevelLabels] |
| 319 | + jsonout['HOTSPOT_COVERAGE'] = xdf.to_dict('split') |
| 320 | + jsonout['HOTSPOT_COVERAGE'].pop('index', None) |
| 321 | + |
| 322 | + xdf = covDf[(covDf['region_type']=='gene') & (covDf['region']!='gene')][['gene','region','info','mean_cvg','min_cvg','max_cvg'] + coverageLevelLabels] |
| 323 | + jsonout['EXON_COVERAGE'] = xdf.to_dict('split') |
| 324 | + jsonout['EXON_COVERAGE'].pop('index', None) |
| 325 | + |
| 326 | + xdf = covDf[(covDf['region_type']=='gene') & (covDf['region']=='gene')][['gene','region','info','mean_cvg','min_cvg','max_cvg'] + coverageLevelLabels] |
| 327 | + jsonout['GENE_COVERAGE'] = xdf.to_dict('split') |
| 328 | + jsonout['GENE_COVERAGE'].pop('index', None) |
| 329 | + |
| 330 | + xdf = covDf[(covDf['region_type']=='sv')][['gene','region','info','mean_cvg','min_cvg','max_cvg'] + coverageLevelLabels] |
| 331 | + jsonout['TRANSCRIPT_COVERAGE'] = xdf.to_dict('split') |
| 332 | + jsonout['TRANSCRIPT_COVERAGE'].pop('index', None) |
| 333 | + |
| 334 | + # biomarker coverage |
| 335 | + xdf = covDf[(covDf['region_type']=='biomarker')][['gene','region','info','mean_cvg','min_cvg','max_cvg'] + coverageLevelLabels] |
| 336 | + jsonout['BIOMARKER_COVERAGE'] = xdf.to_dict('split') |
| 337 | + jsonout['BIOMARKER_COVERAGE'].pop('index', None) |
| 338 | + |
| 339 | + jsonout['HAPLOTECT_LOCI'] = haplotectlocidf.to_dict('split') |
| 340 | + jsonout['HAPLOTECT_LOCI'].pop('index', None) |
| 341 | + |
| 342 | + # dump json to outfile or stdout |
| 343 | + if args.outfile is None: |
| 344 | + json.dump(sanitize_for_json(jsonout),sys.stdout,indent=" ",allow_nan=False) |
| 345 | + |
| 346 | + else: |
| 347 | + j = open(args.outfile, "w") |
| 348 | + json.dump(sanitize_for_json(jsonout),j,indent=" ",allow_nan=False) |
| 349 | + j.close() |
| 350 | + |
| 351 | + |
| 352 | +if __name__ == "__main__": |
| 353 | + main() |
0 commit comments