Skip to content

Using the local databases directly

Search is the usual way in, but each database has its own client, with lookups Search does not expose. This page covers what they share and the few things particular to each. Installing them is covered in Installing the offline databases.

Closing, and with

PubChemID, CompToxID, ZeroPM and CheMBL hold a SQLite file open. They share their connection handling (SQLiteClient), so all four close the same way:

from provesid import PubChemID

with PubChemID() as db:
    inchi = db.cas_to_inchi("50-78-2")
# the file is no longer held open here

db = PubChemID()
db.close()          # idempotent
db.closed           # True

Closing matters on Windows in particular, where an open file cannot be replaced: a download cannot overwrite a database a live object still holds. Querying a closed client raises DatabaseClosedError, which names the client and the file.

Threads

Each thread gets its own connection, opened on that thread's first query and closed with the client, so a thread pool over a list of identifiers is the ordinary thing to write:

from concurrent.futures import ThreadPoolExecutor

with PubChemID() as db:
    with ThreadPoolExecutor(8) as pool:
        rows = list(pool.map(db.get_by_cas, cas_numbers))

close() closes every thread's connection, from whichever thread calls it. A query still running on another thread when it does will fail: closing a client mid-query is a caller error.

A pool is worth it when each item does something else as well — a web lookup, an RDKit canonicalisation, a file read. Measured on 400 CAS numbers, each a PubChemID lookup followed by 20 ms of waiting: 8.52 s serially, 1.17 s on eight threads. A tight loop of nothing but local lookups is the opposite: these queries take tens of microseconds, far less than the GIL handoff around each one, so 5 000 lookups that take 0.29 s serially take 4.80 s on four threads. That is sqlite3 under CPython, and the remedy is not to use a pool for that shape of work.

PubChem properties without the network

PubChemID answers the properties that are data about a compound — its identifiers, names, formula and masses — from disk. properties() reads it first and asks PUG-REST only for what it cannot answer:

from provesid import PubChemID

db = PubChemID()

db.properties(2244, ["MolecularFormula", "MonoisotopicMass"])   # an FTP-built database
# {'CID': 2244, 'Source': 'offline', 'MolecularFormula': 'C9H8O4',
#  'MonoisotopicMass': 180.04225873}

db.properties(2244, ["XLogP"])
# {'CID': 2244, 'Source': 'online', 'XLogP': 1.2}

The Source key records which one answered. Two rules decide it:

  • a CID with no row in the local database goes online;
  • a property with no local column sends the whole request online, because that property needs a request anyway, and a row assembled from two PubChem snapshots is worse than a row from one.

db.offline_properties lists what the open database can serve: the formula, molecular weight, exact and monoisotopic mass, isomeric SMILES, InChI, InChIKey, IUPAC name and title (a Zenodo copy lacks the monoisotopic mass). Everything else is online-only, including PubChem's computed descriptors XLogP, TPSA, Complexity, Charge and the atom and bond counts.

The bulk forms split themselves between the two sources, and fetch the online remainder in batched requests:

rows = db.properties_for_cids(cids, ["MolecularWeight", "InChIKey"])
table = db.properties_table(cids, ["MolecularWeight"])

properties_table() returns a DataFrame with a row for every CID asked about, Source reading offline, online or missing. Pass use_online_fallback=False to keep a lookup strictly local — a guarantee, not a preference.

A property the compound has no value for is absent from the result rather than None, as PubChem itself reports it: 'XLogP' not in result means PubChem computes no logP for that compound.

Descriptors on demand

The local database stores no computed descriptors: XLogP, TPSA and the atom and bond counts are the output of a model run over the structure, and there is more than one model. descriptors() runs one and says which:

db.descriptors(2244)                        # RDKit, from the stored SMILES
# {'CID': 2244, 'Source': 'rdkit', 'MolLogP': 1.3101, 'TPSA': 63.6,
#  'HBondDonorCount': 1, 'HBondAcceptorCount': 3, 'RotatableBondCount': 2,
#  'HeavyAtomCount': 13, 'Charge': 0}

db.descriptors(2244, ["XLogP", "Complexity"], source="pubchem")
# {'CID': 2244, 'Source': 'online', 'XLogP': 1.2, 'Complexity': 212.0}
  • source="rdkit" (default) needs no network for a compound the database holds, and costs about half a millisecond per compound. For one it does not hold, only the SMILES is fetched from PubChem; use_online_fallback=False prevents even that.
  • source="pubchem" is PubChem's own values, over PUG-REST. It is the only way to XLogP and Complexity.

The names are PubChem's wherever the quantity is the same one, so a table can switch source without renaming its columns. The logP is the exception: RDKit's is Crippen's model, not XLogP3, so it is called MolLogP, and asking RDKit for XLogP raises an error that says so.

The numbers differ even where the names agree, because PubChem computes its descriptors with Cactvs. Against PubChem's values for 20 000 random compounds in the database:

descriptor RDKit equals PubChem
HeavyAtomCount, Charge all
HBondDonorCount 94%
RotatableBondCount 74%
TPSA 70%
HBondAcceptorCount 63%
MolLogP vs XLogP 62% within 0.5 log units

Use one source throughout an analysis. descriptors_for_cids() and descriptors_table() are the bulk forms. For a structure that is not in PubChem at all, provesid.pubchem_id.rdkit_descriptors("CCO") computes the same set from a SMILES.

CompTox: synonyms and retired CAS numbers

CompTox stores a chemical's synonyms, former CAS numbers and registry codes together in one |-separated column, which only a full scan can read. CompToxID adds a table holding one row per distinct name of each chemical, so search_by_name(name, exact=True) matches any of them, case-insensitively, in about 40 µs:

from provesid import CompToxID

with CompToxID() as db:
    db.search_by_name("Acetaldoxime", exact=True)[0]["PREFERRED_NAME"]  # 'Acetaldehyde oxime'
    db.get_by_casrn("39400-72-1")                                        # None: not a current number
    db.get_by_alternate_casrn("39400-72-1")["PREFERRED_NAME"]            # 'Atrazine'

get_by_alternate_casrn answers only when exactly one chemical lists the number. Four of the 83 933 such numbers are listed by two unrelated chemicals, and those four go unanswered. Search("cas") asks it whenever get_by_casrn misses, which is how a retired number in an old dataset still resolves offline. On a read-only database file with no name index, exact lookups fall back to preferred names alone, with one warning.

ChEMBL

CheMBL is used by Search for enrichment — it adds ChEMBL IDs to a structure another source found — but it answers lookups by ChEMBL ID, name, SMILES, InChI and InChIKey itself, and reports molecular properties, the parent–salt hierarchy and pesticide classifications. A compacted extract answers every CheMBL method exactly as the full release does. See the ChEMBL tutorial.