import time import requests _API = 'https://string-db.org/api' _TIMEOUT = 60 _CALLER = 'BOTeome' # STRING asks callers to stay under roughly one request per second. _MIN_INTERVAL = 1.0 _last_request = 0.0 MOUSE = 10090 HUMAN = 9606 # Enrichment returns a long tail of publication (PMID) hits that crowd out the # interpretable annotations, so keep a curated set unless asked for everything. _DEFAULT_CATEGORIES = ( 'Process', 'Function', 'Component', 'KEGG', 'RCTM', 'WikiPathways', 'Pfam', 'InterPro', 'SMART', 'Keyword', 'DISEASES', ) _CATEGORY_LABELS = { 'Process': 'GO biological process', 'Function': 'GO molecular function', 'Component': 'GO cellular component', 'KEGG': 'KEGG pathway', 'RCTM': 'Reactome pathway', 'WikiPathways': 'WikiPathways', 'Pfam': 'Pfam domain', 'InterPro': 'InterPro domain', 'SMART': 'SMART domain', 'Keyword': 'UniProt keyword', 'DISEASES': 'Disease association', 'COMPARTMENTS': 'Subcellular compartment', 'TISSUES': 'Tissue expression', 'PMID': 'Publication', } def _get(method, **params): """Throttled JSON request against the STRING API.""" global _last_request wait = _MIN_INTERVAL - (time.monotonic() - _last_request) if wait > 0: time.sleep(wait) params['caller_identity'] = _CALLER r = requests.get(f'{_API}/json/{method}', params=params, timeout=_TIMEOUT) _last_request = time.monotonic() if r.status_code == 400: # STRING explains bad species / unknown identifiers in the body. raise ValueError(f'STRING rejected the request: {r.text.strip()[:300]}') if r.status_code == 404: # Returned when no identifier maps to a STRING protein. raise ValueError( 'STRING recognised none of those identifiers. Check the species is ' 'right, and prefer gene symbols (Gfap) over UniProt accessions.' ) r.raise_for_status() return r.json() def _join(identifiers): """STRING separates identifiers with a carriage return (the %0d in its docs). Pass the raw character: requests percent-encodes the query string itself. """ if isinstance(identifiers, str): identifiers = [i.strip() for i in identifiers.replace(',', '\n').splitlines()] return '\r'.join(i for i in (str(x).strip() for x in identifiers) if i) def map_identifiers(identifiers, species=MOUSE): """Resolve gene names or UniProt accessions to STRING proteins.""" return _get( 'get_string_ids', identifiers=_join(identifiers), species=species, limit=1, echo_query=1, ) def interaction_partners(identifiers, species=MOUSE, limit=10, required_score=400): """Highest-confidence known partners of the given proteins.""" return _get( 'interaction_partners', identifiers=_join(identifiers), species=species, limit=limit, required_score=required_score, ) def network_interactions(identifiers, species=MOUSE, required_score=400): """Interactions *among* the given proteins, ignoring outside partners.""" return _get( 'network', identifiers=_join(identifiers), species=species, required_score=required_score, ) def functional_enrichment(identifiers, species=MOUSE): """GO / pathway / domain enrichment for a protein set.""" return _get('enrichment', identifiers=_join(identifiers), species=species) # --- Formatting ------------------------------------------------------ def format_mapping(rows): if not rows: return 'None of those identifiers matched a STRING protein.' lines = [] for r in rows: annotation = (r.get('annotation') or '').strip() if len(annotation) > 300: annotation = annotation[:300].rstrip() + '...' lines.append( f"{r.get('queryItem', '?')} -> {r.get('preferredName')} " f"({r.get('stringId')}, {r.get('taxonName')})\n {annotation}" ) return '\n'.join(lines) def format_partners(rows): if not rows: return ('No interaction partners above the confidence threshold. ' 'Try a lower required_score.') lines = ['Interaction partners (STRING combined score, 0-1):'] for r in sorted(rows, key=lambda x: x.get('score', 0), reverse=True): lines.append( f" {r.get('preferredName_A')} -- {r.get('preferredName_B')} " f"score={r.get('score')} " f"(experimental={r.get('escore')}, database={r.get('dscore')}, " f"coexpression={r.get('ascore')}, textmining={r.get('tscore')})" ) return '\n'.join(lines) def format_network(rows, queried=None): if not rows: return 'No interactions found among those proteins at this confidence level.' lines = [f'{len(rows)} interactions among the queried proteins:'] for r in sorted(rows, key=lambda x: x.get('score', 0), reverse=True): lines.append( f" {r.get('preferredName_A')} -- {r.get('preferredName_B')} " f"score={r.get('score')}" ) if queried: connected = {r.get('preferredName_A') for r in rows} | {r.get('preferredName_B') for r in rows} lines.append(f'\n{len(connected)} of the queried proteins appear in this network.') return '\n'.join(lines) def format_enrichment(rows, categories=None, top_per_category=10, fdr_cutoff=0.05): if not rows: return 'No functional enrichment found for that protein set.' allowed = None if categories == 'all' else set(categories or _DEFAULT_CATEGORIES) kept = [ r for r in rows if (allowed is None or r.get('category') in allowed) and r.get('fdr', 1) <= fdr_cutoff ] if not kept: return (f'No terms passed FDR <= {fdr_cutoff} in the selected categories ' f'(STRING returned {len(rows)} raw terms).') by_category = {} for r in kept: by_category.setdefault(r['category'], []).append(r) blocks = [f'Functional enrichment ({len(kept)} terms at FDR <= {fdr_cutoff}):'] for category, terms in sorted(by_category.items(), key=lambda kv: -len(kv[1])): label = _CATEGORY_LABELS.get(category, category) terms.sort(key=lambda x: x.get('fdr', 1)) lines = [f'\n## {label}'] for t in terms[:top_per_category]: genes = ', '.join(t.get('preferredNames', [])) lines.append( f" {t.get('description')} [{t.get('term')}]\n" f" FDR={t.get('fdr'):.2g} " f"{t.get('number_of_genes')}/{t.get('number_of_genes_in_background')} genes: {genes}" ) if len(terms) > top_per_category: lines.append(f' ... and {len(terms) - top_per_category} more {label} terms') blocks.append('\n'.join(lines)) return '\n'.join(blocks) # --- Error-tolerant entry points ------------------------------------- def lookup_proteins(identifiers, species=MOUSE): try: return format_mapping(map_identifiers(identifiers, species)) except Exception as e: return f'STRING lookup failed: {e}' def get_partners(identifiers, species=MOUSE, limit=10, required_score=400): try: return format_partners(interaction_partners(identifiers, species, limit, required_score)) except Exception as e: return f'STRING interaction lookup failed: {e}' def get_network(identifiers, species=MOUSE, required_score=400): try: return format_network(network_interactions(identifiers, species, required_score), identifiers) except Exception as e: return f'STRING network lookup failed: {e}' def get_enrichment(identifiers, species=MOUSE, categories=None): try: return format_enrichment(functional_enrichment(identifiers, species), categories) except Exception as e: return f'STRING enrichment failed: {e}'