├── README.md ├── proof.png ├── sctype_py.png ├── sctype_py.py ├── sctype_py_R.png ├── spatial_tutorial.md └── sptype_py.png /README.md: -------------------------------------------------------------------------------- 1 | 2 | # ScType: Fully-automated and ultra-fast cell-type identification using specific marker combinations from single-cell transcriptomic data 3 | 4 | **Article**: [https://doi.org/10.1038/s41467-022-28803-w] 5 | 6 |

ScType a computational method for automated selection of marker genes based merely on scRNA-seq data. The open-source portal (http://sctype.app) provides an interactive web-implementation of the method.

7 | 8 | This GitHub covers the implementation of scType ,originally developed in R, in python. 9 |

10 | ![alt text](https://github.com/IanevskiAleksandr/sc-type/blob/master/ScTypePlan.png) 11 |

12 | 13 | ## Quick start 14 | 15 | ```python 16 | import urllib.request 17 | import scanpy as sc 18 | import numpy as np 19 | import pandas as pd 20 | 21 | # Fetch the script from the URL 22 | url = "https://raw.githubusercontent.com/kris-nader/sc-type-py/main/sctype_py.py" 23 | response = urllib.request.urlopen(url) 24 | script = response.read().decode() 25 | 26 | # Execute the script 27 | exec(script) 28 | ``` 29 | 30 | At this point, running functions equivalent to the sctype R implementation becomes straightforward. There are some differences, such as querying HGNC for approved gene symbols. In the R implementation, the HGNChelper::checkGeneSymbols() function is utilized. However, as no suitable equivalent has been identified, the rest.genenames API is employed instead. 31 | 32 | ```python 33 | # Load the data- this is scaled 34 | adata = sc.read_text("/Users/naderkri/Desktop/sptype/pbmc_scaled.txt",first_column_names=True) 35 | scRNAseqData = pd.DataFrame(adata.X, columns=adata.var_names, index=adata.obs_names) 36 | gs_list = gene_sets_prepare(path_to_db_file="/Users/naderkri/Downloads/ScTypeDB_full.xlsx",cell_type="Immune system") 37 | es_max = sctype_score(scRNAseqData = scRNAseqData, scaled = True, gs = gs_list['gs_positive'], gs2 = gs_list['gs_negative']) 38 | 39 | 40 | ``` 41 | To validate the consistency between scType-py and scType-R annotations, we'll export ex_max to a txt file. Subsequently, we'll use R to overlay the annotations derived from scType-R alongside those from scType-py. 42 | 43 |
44 |

45 | 46 | 47 |

48 |
49 | 50 | ## Integrating scType in a scanpy workflow 51 | On this page, we will highlight the use of sctype on scRNAseq data. For a tutorial on using sctype in python using spatial transcriptomics data, we refer users to the following link. 52 | 53 | ### Load and cluster the data 54 | We load a PBMC 3k example dataset. The raw data can be found here. 55 | ```python 56 | pd.set_option("display.precision", 9) 57 | import urllib.request 58 | import scanpy as sc 59 | import numpy as np 60 | import pandas as pd 61 | 62 | # Fetch the script from the URL 63 | url = "https://raw.githubusercontent.com/kris-nader/sc-type-py/main/sctype_py.py" 64 | response = urllib.request.urlopen(url) 65 | script = response.read().decode() 66 | 67 | # Execute the script 68 | exec(script) 69 | ``` 70 | 71 | Process the single cell data using scanpy. There are slight differences in the results of the scanpy and Seurat workflows. For this reason, we have followed XXX paper discussing potential alterations to the default functions to achieve similar results to the Seurat workflow. 72 | 73 | ```python 74 | import numpy as np 75 | import pandas as pd 76 | 77 | np.random.seed(100) 78 | adata=sc.read_10x_mtx("./filtered_gene_bc_matrices/hg19/") 79 | sc.pp.filter_cells(adata, min_genes=200) 80 | sc.pp.filter_genes(adata, min_cells=3) 81 | ``` 82 | 83 | Normalize, scale and cluster the data. 84 | ```python 85 | adata.layers["counts"] = adata.X.copy() 86 | sc.pp.normalize_total(adata,target_sum=1e4) 87 | sc.pp.log1p(adata) 88 | sc.pp.highly_variable_genes(adata, n_top_genes=2000, flavor="seurat_v3",layer="counts") 89 | adata.raw = adata 90 | 91 | # Scale and run PCA 92 | sc.pp.scale(adata,max_value=10) 93 | scaled_data = pd.DataFrame(adata.X) 94 | # change column indexes 95 | scaled_data.columns =adata.var_names 96 | # Change the row indexes 97 | scaled_data.index = adata.obs_names 98 | scaled_data=scaled_data.T 99 | 100 | 101 | sc.tl.pca(adata,zero_center=False) 102 | sc.pp.neighbors(adata, n_neighbors=20, n_pcs=10,use_rep="X_pca") 103 | sc.tl.leiden(adata, resolution=0.8) 104 | sc.tl.umap(adata,min_dist=0.3) 105 | sc.pl.umap(adata, color=['leiden']) 106 | ``` 107 | 108 | Now, we can automatically assign cell types using ScType. For that, we first load 2 additional ScType functions: gene_sets_prepare and sctype_score 109 | These functions may be a bit slower than the original implementation in R. As stated earlier, this is due to the fact that there is no well estabilshed package to accuratly retrieve approved gene symbols. For this reason, we decided to query the HGNC database for approved symbols. 110 | 111 | Users can prepare their gene input cell marker file or use the sctypeDB. The input XLSX must be formatted in the same way as the original scTypeDB. DB file should contain four columns (tissueType - tissue type, cellName - cell type, geneSymbolmore1 - positive marker genes, geneSymbolmore2 - marker genes not expected to be expressed by a cell type) 112 | 113 | 114 | ```python 115 | scRNAseqData=scaled_data 116 | gs_list=gene_sets_prepare(path_to_db_file="https://raw.githubusercontent.com/IanevskiAleksandr/sc-type/master/ScTypeDB_full.xlsx",cell_type="Immune system") 117 | es_max = sctype_score(scRNAseqData = scRNAseqData, scaled = True, gs = gs_list['gs_positive'], gs2 = gs_list['gs_negative']) 118 | 119 | unique_clusters = adata.obs['leiden'].unique() 120 | # Apply the function to each unique cluster and combine the results into a DataFrame 121 | cL_results = pd.concat([process_cluster(cluster,adata,es_max,'leiden') for cluster in unique_clusters]) 122 | 123 | # Group by cluster and select the top row based on scores 124 | sctype_scores = cL_results.groupby('cluster').apply(lambda x: x.nlargest(1, 'scores')).reset_index(drop=True) 125 | 126 | # Set low-confidence clusters to "Unknown" 127 | sctype_scores.loc[sctype_scores['scores'] < sctype_scores['ncells'] / 4, 'type'] = 'Unknown' 128 | 129 | # Iterate over unique clusters 130 | adata.obs['sctype_classification'] = "" 131 | for cluster in sctype_scores['cluster'].unique(): 132 | # Filter sctype_scores for the current cluster 133 | cl_type = sctype_scores[sctype_scores['cluster'] == cluster] 134 | # Get the type for the current cluster 135 | cl_type_value = cl_type['type'].iloc[0] 136 | # Update 'sctype_classification' in pbmc.obs for cells belonging to the current cluster 137 | adata.obs.loc[adata.obs['leiden'] == cluster, 'sctype_classification'] = cl_type_value 138 | 139 | # Plot the UMAP with sctype_classification as labels 140 | sc.pl.umap(adata, color='sctype_classification', title='UMAP with sctype_classification') 141 | ``` 142 | 143 |

144 | 145 |

146 | 147 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /proof.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kris-nader/sc-type-py/59a615e2b68813981624141b388e41d89336898f/proof.png -------------------------------------------------------------------------------- /sctype_py.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kris-nader/sc-type-py/59a615e2b68813981624141b388e41d89336898f/sctype_py.png -------------------------------------------------------------------------------- /sctype_py.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | from sklearn.preprocessing import scale, MinMaxScaler 4 | import requests 5 | import xml.etree.ElementTree as ET 6 | import scanpy as sc 7 | from collections import defaultdict 8 | import concurrent.futures 9 | import multiprocessing 10 | from functools import partial 11 | from concurrent.futures import ThreadPoolExecutor 12 | from requests.adapters import HTTPAdapter 13 | from urllib3.util.retry import Retry 14 | 15 | 16 | 17 | 18 | 19 | def gene_sets_prepare(path_to_db_file, cell_type): 20 | # Read data from Excel file 21 | cell_markers = pd.read_excel(path_to_db_file) 22 | # Filter by cell_type 23 | cell_markers = cell_markers[cell_markers['tissueType'] == cell_type] 24 | # Preprocess geneSymbolmore1 and geneSymbolmore2 columns 25 | #for col in ['geneSymbolmore1', 'geneSymbolmore2']: 26 | # cell_markers[col] = cell_markers[col].str.replace(" ", "").str.upper() 27 | for col in ['geneSymbolmore1', 'geneSymbolmore2']: 28 | if col in cell_markers.columns: 29 | cell_markers[col] = cell_markers[col].fillna('').str.replace(" ", "").str.upper() 30 | # Stack and drop duplicates to get unique gene names 31 | gene_names = pd.concat([cell_markers['geneSymbolmore1'], cell_markers['geneSymbolmore2']]).str.split(',', expand=True).stack().drop_duplicates().reset_index(drop=True) 32 | gene_names = gene_names[gene_names.str.strip() != ''] 33 | gene_names = gene_names[gene_names != 'None'].unique() 34 | # Get approved symbols for gene names 35 | res = get_gene_symbols(set(gene_names)) 36 | res = dict(zip(res['Gene'], res['Symbol'])) 37 | # Process gene symbols 38 | for col in ['geneSymbolmore1', 'geneSymbolmore2']: 39 | cell_markers[col] = cell_markers[col].apply(lambda row: process_gene_symbols(row, res)).str.replace("///", ",").str.replace(" ", "") 40 | # Group by cellName and create dictionaries of gene sets 41 | gs_positive = cell_markers.groupby('cellName')['geneSymbolmore1'].apply(lambda x: list(set(','.join(x).split(',')))).to_dict() 42 | gs_negative = cell_markers.groupby('cellName')['geneSymbolmore2'].apply(lambda x: list(set(','.join(x).split(',')))).to_dict() 43 | return {'gs_positive': gs_positive, 'gs_negative': gs_negative} 44 | 45 | 46 | def process_gene_symbols(gene_symbols, res): 47 | if pd.isnull(gene_symbols): 48 | return "" 49 | markers_all = gene_symbols.upper().split(',') 50 | markers_all = [marker.strip().upper() for marker in markers_all if marker.strip().upper() not in ['NA', '']] 51 | markers_all = sorted(markers_all) 52 | if len(markers_all) > 0: 53 | markers_all = [res.get(marker) for marker in markers_all] 54 | markers_all = [symbol for symbol in markers_all if symbol is not None] 55 | markers_all = list(set(markers_all)) 56 | return ','.join(markers_all) 57 | else: 58 | return "" 59 | 60 | 61 | 62 | # ============================================================================= 63 | # def get_gene_symbols(genes): 64 | # data = {"Gene": [], "Symbol": []} 65 | # for gene in genes: 66 | # session = requests.Session() 67 | # retry = Retry(connect=3, backoff_factor=0.5) 68 | # adapter = HTTPAdapter(max_retries=retry) 69 | # session.mount('http://', adapter) 70 | # session.mount('https://', adapter) 71 | # url = f"https://rest.genenames.org/fetch/symbol/{gene}" 72 | # response = session.get(url) 73 | # if response.status_code == 200: 74 | # root = ET.fromstring(response.content) 75 | # result_elem = root.find("result") 76 | # if result_elem.get("numFound") == "0": 77 | # url = f"https://rest.genenames.org/search/alias_symbol/{gene}" 78 | # response = session.get(url) 79 | # if response.status_code == 200: 80 | # root = ET.fromstring(response.content) 81 | # result_elem = root.find("result") 82 | # if result_elem is not None and result_elem.get("numFound") != "0": 83 | # symbols = [doc.find('str[@name="symbol"]').text for doc in root.findall('.//doc')] 84 | # data["Gene"].append(gene) 85 | # data["Symbol"].append(','.join(symbols)) 86 | # if result_elem is not None and result_elem.get("numFound") == "0": 87 | # url = f"https://rest.genenames.org/search/prev_symbol/{gene}" 88 | # response = session.get(url) 89 | # if response.status_code == 200: 90 | # root = ET.fromstring(response.content) 91 | # result_elem = root.find("result") 92 | # if result_elem is not None and result_elem.get("numFound") == "0": 93 | # symbol_element = root.find('.//str[@name="symbol"]') 94 | # data["Gene"].append(gene) 95 | # data["Symbol"].append(symbol_element) 96 | # else: 97 | # data["Gene"].append(gene) 98 | # data["Symbol"].append(gene) 99 | # else: 100 | # print(f"Failed to retrieve data for gene {gene}. Status code:", response.status_code) 101 | # df = pd.DataFrame(data) 102 | # return df 103 | # 104 | # 105 | # ============================================================================= 106 | 107 | 108 | def get_gene_symbols(genes): 109 | data = {"Gene": [], "Symbol": []} 110 | for gene in genes: 111 | session = requests.Session() 112 | retry = Retry(connect=3, backoff_factor=0.5) 113 | adapter = HTTPAdapter(max_retries=retry) 114 | session.mount('http://', adapter) 115 | session.mount('https://', adapter) 116 | url = f"https://rest.genenames.org/fetch/symbol/{gene}" 117 | response = session.get(url) 118 | if response.status_code == 200: 119 | root = ET.fromstring(response.content) 120 | result_elem = root.find("result") 121 | if result_elem is not None and result_elem.get("numFound") == "0": 122 | url = f"https://rest.genenames.org/search/alias_symbol/{gene}" 123 | response = session.get(url) 124 | if response.status_code == 200: 125 | root = ET.fromstring(response.content) 126 | result_elem = root.find("result") 127 | if result_elem is not None and result_elem.get("numFound") != "0": 128 | symbols = [doc.find('str[@name="symbol"]').text for doc in root.findall('.//doc')] 129 | data["Gene"].append(gene) 130 | data["Symbol"].append(','.join(symbols)) 131 | elif result_elem is not None and result_elem.get("numFound") == "0": 132 | url = f"https://rest.genenames.org/search/prev_symbol/{gene}" 133 | response = session.get(url) 134 | if response.status_code == 200: 135 | root = ET.fromstring(response.content) 136 | result_elem = root.find("result") 137 | if result_elem is not None and result_elem.get("numFound") != "0": 138 | symbol_element = root.find('.//str[@name="symbol"]').text 139 | data["Gene"].append(gene) 140 | data["Symbol"].append(symbol_element) 141 | else: 142 | print(f"Failed to retrieve data for gene {gene}. Status code:", response.status_code) 143 | else: 144 | symbol_element = root.find('.//str[@name="symbol"]').text 145 | data["Gene"].append(gene) 146 | data["Symbol"].append(gene) 147 | else: 148 | print(f"Failed to retrieve data for gene {gene}. Status code:", response.status_code) 149 | df = pd.DataFrame(data) 150 | return df 151 | 152 | 153 | 154 | def sctype_score(scRNAseqData, scaled=True, gs=None, gs2=None, gene_names_to_uppercase=True, *args, **kwargs): 155 | marker_stat = defaultdict(int, {gene: sum(gene in genes for genes in gs.values()) for gene in set(gene for genes in gs.values() for gene in genes)}) 156 | marker_sensitivity = pd.DataFrame({'gene_': list(marker_stat.keys()), 'score_marker_sensitivity': list(marker_stat.values())}) 157 | # Rescaling the score_marker_sensitivity column 158 | # grab minimum and maximum 159 | min_value=1 160 | max_value= len(gs) 161 | # Apply the formula to the column 162 | marker_sensitivity['score_marker_sensitivity'] = 1 - (marker_sensitivity['score_marker_sensitivity'] - min_value) / (max_value - min_value) 163 | # Convert gene names to Uppercase 164 | if gene_names_to_uppercase: 165 | scRNAseqData.index = scRNAseqData.index.str.upper() 166 | # Subselect genes only found in data 167 | names_gs_cp = list(gs.keys()) 168 | names_gs_2_cp = list(gs2.keys()) 169 | gs_ = {key: [gene for gene in scRNAseqData.index if gene in gs[key]] for key in gs} 170 | gs2_ = {key: [gene for gene in scRNAseqData.index if gene in gs2[key]] for key in gs2} 171 | gs__ = dict(zip(names_gs_cp, gs_.values())) 172 | gs2__ = dict(zip(names_gs_2_cp, gs2_.values())) 173 | cell_markers_genes_score = marker_sensitivity[marker_sensitivity['gene_'].isin(set.union(*map(set, gs__.values())))] 174 | # Z-scale if not 175 | if not scaled: 176 | Z = scale(scRNAseqData.T).T 177 | else: 178 | Z = scRNAseqData 179 | # Multiply by marker sensitivity 180 | for _, row in cell_markers_genes_score.iterrows(): 181 | Z.loc[row['gene_']] *= row['score_marker_sensitivity'] 182 | marker_genes=list(set().union(*gs__.values(), *gs2__.values())) 183 | Z = Z.loc[marker_genes] 184 | # Combine scores 185 | es = pd.DataFrame(index=gs__.keys(),columns=Z.columns,data=np.zeros((len(gs__), Z.shape[1]))) 186 | for gss_, genes in gs__.items(): 187 | for j in range(Z.shape[1]): 188 | gs_z = Z.loc[genes, Z.columns[j]] 189 | gz_2 = Z.loc[gs2__[gss_], Z.columns[j]] * -1 if gs2__ and gss_ in gs2__ else pd.Series(dtype=np.float64) 190 | sum_t1 = np.sum(gs_z) / np.sqrt(len(gs_z)) 191 | sum_t2 = np.sum(gz_2) / np.sqrt(len(gz_2)) if not gz_2.empty else 0 192 | if pd.isna(sum_t2): 193 | sum_t2 = 0 194 | es.loc[gss_, Z.columns[j]] = sum_t1 + sum_t2 195 | es = es.dropna(how='all') 196 | return es 197 | 198 | def process_cluster(cluster,adata,es_max,clustering): 199 | cluster_data = es_max.loc[:, adata.obs.index[adata.obs[clustering] == cluster]] 200 | es_max_cl = cluster_data.sum(axis=1).sort_values(ascending=False) 201 | top_scores = es_max_cl.head(10) 202 | ncells = sum(adata.obs[clustering] == cluster) 203 | return pd.DataFrame({ 204 | 'cluster': cluster, 205 | 'type': top_scores.index, 206 | 'scores': top_scores.values, 207 | 'ncells': ncells 208 | }) 209 | -------------------------------------------------------------------------------- /sctype_py_R.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kris-nader/sc-type-py/59a615e2b68813981624141b388e41d89336898f/sctype_py_R.png -------------------------------------------------------------------------------- /spatial_tutorial.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # SpType: ScType enables fast and accurate cell type identification from spatial transcriptomics data 4 | 5 | 6 | **Article**: TBA 7 | 8 | In this study, we adapt and showcase the application of scType, renowned for its speed, transparency, and user-friendly interface, to efficiently annotate cell types in **spatial transcriptomics data**. 9 | 10 | 11 | Please refer to the original ScType paper and Github for more information on the method. 12 | 13 | 14 |
15 | 16 | ![alt text](https://github.com/kris-nader/sp-type/blob/main/sctype_goes_spatial_fig.png) 17 | 18 | 19 |
20 | We start with by loading all required functions for this analysis. These are the original scType functions rewritten in python and applied on spatial transcriptomics data. 21 | 22 | ```python 23 | 24 | import urllib.request 25 | import scanpy as sc 26 | import numpy as np 27 | import pandas as pd 28 | np.random.seed(100) 29 | 30 | 31 | # Fetch the script from the URL 32 | url = "https://raw.githubusercontent.com/kris-nader/sc-type-py/main/sctype_py.py" 33 | response = urllib.request.urlopen(url) 34 | script = response.read().decode() 35 | 36 | # Execute the script 37 | exec(script) 38 | ``` 39 | Load sample data available through Scanpy. Specifically, we will use the "Mouse Brain Sagittal Anterior" dataset generated using V1 Chromium technology. 40 | In this tutorial, we will follow the preprocessing steps outlined by Scanpy in their basic analysis tutorial see scanpy tutorial for more details. 41 | Please note that variations in preprocessing choices can lead to different annotations(SCtransform vs LogNormalize or Leiden vs Louvain). Additionally, the annotations produced can vary significantly between different packages, such as Seurat and Scanpy, as discussed in this paper. 42 | 43 | ```python 44 | adata = sc.datasets.visium_sge(sample_id="V1_Mouse_Brain_Sagittal_Anterior") 45 | adata.var_names_make_unique() 46 | adata.layers["counts"] = adata.X.copy() 47 | 48 | sc.pp.highly_variable_genes(adata, n_top_genes=2000, flavor="seurat_v3",layer="counts") 49 | sc.pp.normalize_total(adata) 50 | sc.pp.log1p(adata) 51 | adata.raw = adata 52 | 53 | # Scale and run PCA 54 | sc.pp.scale(adata,max_value=10) 55 | scaled_data = pd.DataFrame(adata.X) 56 | # change column indexes 57 | scaled_data.columns =adata.var_names 58 | # Change the row indexes 59 | scaled_data.index = adata.obs_names 60 | scaled_data=scaled_data.T 61 | 62 | 63 | sc.tl.pca(adata,zero_center=False,random_state=0) 64 | sc.pp.neighbors(adata, n_neighbors=20, n_pcs=10,use_rep="X_pca",random_state=0) 65 | sc.tl.leiden(adata,resolution=0.8,n_iterations=10) 66 | 67 | # Visualize clusters using UMAP 68 | sc.tl.umap(adata,min_dist=0.3) 69 | sc.pl.umap(adata, color=['leiden']) 70 | sc.pl.spatial(adata, img_key="hires", color=["leiden"]) 71 | ``` 72 | 73 | 74 | Now, let's run the sctype functions. This involves several steps: querying the HGNC database for approved gene symbols, calculating sctype scores, aggregating these scores based on cluster information, and overlaying the results onto a lower-dimensional space such as UMAP or t-SNE. 75 | Users are welcome to use their own custom marker datasets. For this example, we will use the default scTypeDB, which contains annotations for various healthy tissues. For more detailed information, please refer to the original paper. 76 | 77 | ```python 78 | 79 | scRNAseqData=scaled_data 80 | gs_list=gene_sets_prepare(path_to_db_file="https://raw.githubusercontent.com/IanevskiAleksandr/sc-type/master/ScTypeDB_full.xlsx" ,cell_type="Brain") 81 | 82 | es_max = sctype_score(scRNAseqData = scRNAseqData, scaled = True, gs = gs_list['gs_positive'], gs2 = gs_list['gs_negative']) 83 | unique_clusters = adata.obs['leiden'].unique() 84 | # Apply the function to each unique cluster and combine the results into a DataFrame 85 | cL_results = pd.concat([process_cluster(cluster,adata,es_max,"leiden") for cluster in unique_clusters]) 86 | 87 | # Group by cluster and select the top row based on scores 88 | sctype_scores = cL_results.groupby('cluster').apply(lambda x: x.nlargest(1, 'scores')).reset_index(drop=True) 89 | 90 | # Set low-confidence clusters to "Unknown" 91 | sctype_scores.loc[sctype_scores['scores'] < sctype_scores['ncells'] / 4, 'type'] = 'Unknown' 92 | 93 | adata.obs['sctype_classification'] = "" 94 | 95 | # Iterate over unique clusters 96 | for cluster in sctype_scores['cluster'].unique(): 97 | # Filter sctype_scores for the current cluster 98 | cl_type = sctype_scores[sctype_scores['cluster'] == cluster] 99 | # Get the type for the current cluster 100 | cl_type_value = cl_type['type'].iloc[0] 101 | # Update 'sctype_classification' in pbmc.obs for cells belonging to the current cluster 102 | adata.obs.loc[adata.obs['leiden'] == cluster, 'sctype_classification'] = cl_type_value 103 | 104 | # Plot the UMAP with sctype_classification as labels 105 | sc.pl.umap(adata, color='sctype_classification', title='UMAP with sctype_classification') 106 | sc.pl.spatial(adata, img_key="hires", color=["sctype_classification"]) 107 | ``` 108 | 109 |
110 |

111 | 112 |

113 |
114 | 115 | -------------------------------------------------------------------------------- /sptype_py.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kris-nader/sc-type-py/59a615e2b68813981624141b388e41d89336898f/sptype_py.png --------------------------------------------------------------------------------