Skip to content

PubChemID

Offline: the PubChem compounds that carry a CAS number, from pubchem_id.db. provesid.pubchem_ftp builds that database from PubChem's FTP site. See Installing the offline databases and Using the local databases directly.

provesid.pubchem_id

The offline PubChem identifier database: PubChemID.

pubchem_id.db is a local SQLite file of the ~1.43 M PubChem compounds that carry a CAS number, with their identifiers, names, synonyms, formula and masses. PubChemID answers lookups against it with no network, and falls back to PUG-REST (through PubChemAPI) only for what the file does not hold, the computed descriptors among them.

The file is built from PubChem's FTP site by provesid.pubchem_ftp, or downloaded prebuilt from Zenodo. The online client lives in provesid.pubchem.

This module also holds rdkit_descriptors, the RDKit computation behind PubChemID.descriptors, for structures that are not in PubChem.

Examples:

>>> from provesid import PubChemID
>>> with PubChemID() as db:
...     db.cas_to_cid("50-78-2")
2244

Attributes

RDKIT_DESCRIPTORS module-attribute

Descriptors rdkit_descriptors computes, in the order it reports them. The names are PubChem's wherever the quantity is the same one --- a polar surface area, a count of donors --- so that a table can switch source without renaming its columns. The logP is the exception: PubChem's XLogP is the XLogP3 model and RDKit's is Crippen's, a different model with a different number, so it keeps RDKit's name, MolLogP. PubChem's Complexity has no RDKit counterpart and is not here.

PUBCHEM_DESCRIPTORS module-attribute

The computed descriptors PubChem publishes, which pubchem_id.db no longer stores (see PubChemID.descriptors).

Classes

PubChemID

Bases: SQLiteClient

Interface to PubChem ID SQLite database for fast identifier lookup and conversion.

This class provides access to a local SQLite database of the ~1.43 M PubChem compounds that carry a CAS number, with their identifiers (CID, CAS, InChI, InChIKey, SMILES), names, synonyms, formula and masses.

Where the database comes from is the source argument, and only matters when there is none on disk yet:

  • "ftp" (default) builds it from a dated monthly snapshot of PubChem's FTP site with provesid.pubchem_ftp.build_pubchem_id_db. The result records its release and the MD5 of every source file (see provenance), and carries cross-references to DSSTox, ChEBI, ChEMBL, EC and UNII (see xrefs).
  • "zenodo" downloads a prebuilt copy, refreshed by hand every few months. Quicker to fetch, but it is whatever release it was built from.

Both hold the same tables, so every lookup works on either. They differ in the property columns: a database built from FTP has MonoisotopicMass and none of the eight computed descriptors (XLogP, TPSA and the like). descriptors computes those with RDKit from the stored SMILES, or fetches PubChem's own on request; properties fetches PubChem's. See offline_properties for what the open database can answer.

Connection handling comes from SQLiteClient: use the class as a context manager, or call close when finished, and query it from as many threads as you like --- each gets its own connection.

Attributes:

Name Type Description
db_path str

Path to the SQLite database file

conn Connection

This thread's database connection

source str

The acquisition route this instance was given.

offline_properties dict

The part of OFFLINE_PROPERTIES the open database has columns for --- what properties answers without the network, and retrieves when no properties are named.

Examples:

>>> from provesid import PubChemID
>>> with PubChemID() as db:
...     db.get_by_cas("50-78-2")["cmpdname"]
...     db.cas_to_inchikey("50-78-2")
...     db.inchikey_to_cid("BSYNRYMUTXBXSQ-UHFFFAOYSA-N")
...     db.batch_cas_to_cid(["50-78-2", "50-00-0"])
'Aspirin'
'BSYNRYMUTXBXSQ-UHFFFAOYSA-N'
2244
{'50-78-2': 2244, '50-00-0': 712}
Source code in src/provesid/pubchem_id.py
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
class PubChemID(SQLiteClient):
    """
    Interface to PubChem ID SQLite database for fast identifier lookup and conversion.

    This class provides access to a local SQLite database of the ~1.43 M PubChem
    compounds that carry a CAS number, with their identifiers (CID, CAS, InChI,
    InChIKey, SMILES), names, synonyms, formula and masses.

    Where the database comes from is the ``source`` argument, and only matters
    when there is none on disk yet:

    * ``"ftp"`` (default) builds it from a dated monthly snapshot of PubChem's
      FTP site with
      [`provesid.pubchem_ftp.build_pubchem_id_db`][provesid.pubchem_ftp.build_pubchem_id_db].
      The result records its release and the MD5 of every source file (see
      [`provenance`][provesid.pubchem_id.PubChemID.provenance]), and carries
      cross-references to DSSTox, ChEBI, ChEMBL, EC and UNII (see
      [`xrefs`][provesid.pubchem_id.PubChemID.xrefs]).
    * ``"zenodo"`` downloads a prebuilt copy, refreshed by hand every few
      months. Quicker to fetch, but it is whatever release it was built from.

    Both hold the same tables, so every lookup works on either. They differ in
    the property columns: a database built from FTP has ``MonoisotopicMass``
    and none of the eight computed descriptors (XLogP, TPSA and the like).
    [`descriptors`][provesid.pubchem_id.PubChemID.descriptors] computes those
    with RDKit from the stored SMILES, or fetches PubChem's own on request;
    [`properties`][provesid.pubchem_id.PubChemID.properties] fetches PubChem's.
    See [`offline_properties`][provesid.pubchem_id.PubChemID] for what the open
    database can answer.

    Connection handling comes from
    [`SQLiteClient`][provesid.sqlite_client.SQLiteClient]: use the class as a context
    manager, or call [`close`][provesid.sqlite_client.SQLiteClient.close] when
    finished, and query it from as many threads as you like --- each gets its
    own connection.

    Attributes:
        db_path (str): Path to the SQLite database file
        conn (sqlite3.Connection): This thread's database connection
        source (str): The acquisition route this instance was given.
        offline_properties (dict): The part of
            [`OFFLINE_PROPERTIES`][provesid.pubchem_id.PubChemID.OFFLINE_PROPERTIES]
            the open database has columns for --- what
            [`properties`][provesid.pubchem_id.PubChemID.properties] answers
            without the network, and retrieves when no properties are named.

    Examples:
        >>> from provesid import PubChemID
        >>> with PubChemID() as db:
        ...     db.get_by_cas("50-78-2")["cmpdname"]
        ...     db.cas_to_inchikey("50-78-2")
        ...     db.inchikey_to_cid("BSYNRYMUTXBXSQ-UHFFFAOYSA-N")
        ...     db.batch_cas_to_cid(["50-78-2", "50-00-0"])
        'Aspirin'
        'BSYNRYMUTXBXSQ-UHFFFAOYSA-N'
        2244
        {'50-78-2': 2244, '50-00-0': 712}
    """

    DEFAULT_DB_NAME = "pubchem_id.db"
    DEFAULT_DB_URL = "https://zenodo.org/records/18173204/files/pubchem_id.db"

    SOURCES = ("ftp", "zenodo")
    """Where a missing database comes from. ``"ftp"`` builds it from PubChem's
    FTP site ([`provesid.pubchem_ftp`][provesid.pubchem_ftp]); ``"zenodo"`` downloads a
    prebuilt copy.
    """

    OFFLINE_PROPERTIES = {
        'MolecularFormula': 'mf',
        'MolecularWeight': 'mw',
        'SMILES': 'smiles',
        'InChI': 'inchi',
        'InChIKey': 'inchikey',
        'IUPACName': 'iupacname',
        'Title': 'cmpdname',
        'ExactMass': 'exactmass',
        'MonoisotopicMass': 'monoisotopicmass',
    }
    """PubChem property names the local database can answer, mapped to their
    column in the ``compounds`` table. These are the properties that are
    *data* about a compound --- its identifiers, names, formula and masses.
    The computed descriptors (``XLogP``, ``TPSA``, ``Complexity`` and the
    counts) are not served from disk even by a Zenodo database that still
    holds them: they are PubChem's model outputs, and a user who asks for
    them gets PubChem's current values, labelled ``Source='online'``, or
    RDKit's from [`descriptors`][provesid.pubchem_id.PubChemID.descriptors],
    labelled ``Source='rdkit'``. Note that ``smiles`` holds the isomeric
    SMILES, which is what PubChem now calls ``SMILES``; the
    stereochemistry-free ``ConnectivitySMILES`` is not stored locally.
    ``MolecularWeight`` is computed from the formula when the database is built
    from FTP --- PubChem's files do not carry it --- and agrees with PubChem's
    to the second decimal for most compounds.
    """

    DEFAULT_PROPERTIES = tuple(OFFLINE_PROPERTIES)
    """What [`properties`][provesid.pubchem_id.PubChemID.properties] retrieves
    when the caller names no properties, for a database that has every column.
    An open database uses [`offline_properties`][provesid.pubchem_id.PubChemID]
    instead, so that a Zenodo copy without ``monoisotopicmass`` does not send
    every default lookup online.
    """

    _PROPERTY_CASTS = {
        'MolecularWeight': float,
        'ExactMass': float,
        'MonoisotopicMass': float,
        'XLogP': float,
        'TPSA': float,
        'Complexity': float,
        'Charge': int,
        'HBondDonorCount': int,
        'HBondAcceptorCount': int,
        'RotatableBondCount': int,
        'HeavyAtomCount': int,
    }
    """Type each property is normalised to, so that a table assembled from both
    sources is usable as one table. PUG-REST reports ``MolecularWeight`` and
    ``ExactMass`` as strings while the local database holds floats.
    """

    _SQL_PARAMETER_LIMIT = 500
    """Bound parameters per ``IN`` clause. SQLite's own default ceiling is 999."""


    def __init__(
        self,
        db_path: Optional[str] = None,
        auto_download: bool = True,
        data_dir: Optional[str] = None,
        db_url: Optional[str] = None,
        redownload: bool = False,
        api: Optional['PubChemAPI'] = None,
        source: str = "ftp",
    ):
        """
        Initialize PubChemID database connection.

        Args:
            db_path (str, optional): Path to SQLite database. If None, uses default
                                    location in the persistent user dataset directory.
            auto_download (bool): If True, acquire the database from ``source``
                when it is not on disk. Default is True.
            data_dir (str, optional): Directory to store the database when
                ``db_path`` is not provided.
            db_url (str, optional): Download URL for ``source="zenodo"``. If
                None, uses the package default URL.
            redownload (bool): If True, acquire the database again even though
                one is on disk, when ``auto_download`` is enabled. With
                ``source="ftp"`` that is a rebuild from the newest snapshot.
            api (PubChemAPI, optional): Online client used by
                [`properties`][provesid.pubchem_id.PubChemID.properties] when
                the local database cannot answer a request. One is created on
                first use if none is given, so passing this is only needed to
                share a client or to configure its pause time.
            source (str): How a missing database is acquired, one of
                [`SOURCES`][provesid.pubchem_id.PubChemID.SOURCES]. ``"ftp"``
                (default) builds it from the newest monthly snapshot of
                PubChem's FTP site: 15.4 GB transferred, a 2.5 GB database plus
                7.4 GB of free disk at peak, and about 12 minutes of processing
                on top of the download time. ``"zenodo"`` downloads a 2.2 GB
                prebuilt copy. It describes an acquisition, not a file: a
                database already on disk is opened whichever way it was made.

        Raises:
            ValueError: If ``source`` is not one of
                [`SOURCES`][provesid.pubchem_id.PubChemID.SOURCES]. Checked
                before anything is fetched.
            FileNotFoundError: If database file doesn't exist and auto_download is False

        Examples:
            >>> db = PubChemID()                       # the default location
            >>> db.source
            'ftp'
            >>> PubChemID(db_path="/no/such/pubchem_id.db", auto_download=False)
            Traceback (most recent call last):
            ...
            FileNotFoundError: PubChem ID database not found at /no/such/pubchem_id.db. ...
            >>> PubChemID(source="ncbi")
            Traceback (most recent call last):
            ...
            ValueError: PubChemID(source='ncbi') is not a download route. Use one of 'ftp', 'zenodo'.
        """
        self.logger = logging.getLogger(__name__)
        self.source = self._validate_source(source)
        self.db_url = db_url or self.DEFAULT_DB_URL
        self._api = api

        if db_path is None:
            base_dir = data_dir or user_dataset_path()
            db_path = os.path.join(base_dir, self.DEFAULT_DB_NAME)

        self.db_path = os.path.abspath(os.path.expanduser(db_path))

        needs_download = redownload or not os.path.exists(self.db_path)

        if needs_download:
            if auto_download:
                if redownload and os.path.exists(self.db_path):
                    self.logger.info(
                        "Forced PubChemID redownload requested for: %s", self.db_path
                    )
                else:
                    self.logger.info("Database not found at %s", self.db_path)
                self._acquire(force=redownload)
            else:
                raise FileNotFoundError(
                    f"PubChem ID database not found at {self.db_path}. "
                    "Set auto_download=True, run "
                    "provesid.pubchem_ftp.build_pubchem_id_db(), or run "
                    "PubChemID.download_database()."
                )

        # One connection per thread, released by close() or by leaving a
        # ``with`` block --- see
        # ``SQLiteClient``.
        self._open_database(self.db_path)
        self.offline_properties = self._available_offline_properties()

    @classmethod
    def _validate_source(cls, source: str) -> str:
        """
        Check an acquisition route name before anything is fetched.

        Args:
            source: The ``source`` argument as given.

        Returns:
            The same value, once it is known to be a route.

        Raises:
            ValueError: If it is not one of
                [`SOURCES`][provesid.pubchem_id.PubChemID.SOURCES].
        """
        if source in cls.SOURCES:
            return source
        options = ", ".join(repr(name) for name in cls.SOURCES)
        raise ValueError(f"PubChemID(source={source!r}) is not a download route. "
                         f"Use one of {options}.")

    def _acquire(self, *, force: bool) -> None:
        """Put a database at [`db_path`][provesid.pubchem_id.PubChemID] by the
        route [`source`][provesid.pubchem_id.PubChemID] names."""
        if self.source == "ftp":
            from .pubchem_ftp import build_pubchem_id_db

            self.logger.info("Building the PubChem ID database from PubChem's FTP site")
            build_pubchem_id_db(self.db_path, force=force)
        else:
            self.logger.info("Downloading the PubChem ID database from Zenodo")
            self.download_database(db_path=self.db_path, zenodo_url=self.db_url,
                                   force=force)

    def _available_offline_properties(self) -> Dict[str, str]:
        """
        The part of
        [`OFFLINE_PROPERTIES`][provesid.pubchem_id.PubChemID.OFFLINE_PROPERTIES]
        this database has columns for.

        A database built from FTP and one downloaded from Zenodo differ in one
        column --- ``monoisotopicmass`` exists only in the first --- so which
        properties can be answered from disk is a fact about the file, not the
        class.
        """
        columns = {row[1] for row in self.conn.execute("PRAGMA table_info(compounds)")}
        return {name: column for name, column in self.OFFLINE_PROPERTIES.items()
                if column in columns}

    @staticmethod
    def download_database(
        db_path: Optional[str] = None,
        zenodo_url: Optional[str] = None,
        force: bool = False,
    ) -> str:
        """
        Download PubChem ID database from Zenodo --- the ``source="zenodo"`` route.

        To build it from PubChem's own files instead, which is the default
        route, see
        [`provesid.pubchem_ftp.build_pubchem_id_db`][provesid.pubchem_ftp.build_pubchem_id_db].

        The transfer is resumable: an interrupted download leaves a ``.part``
        file beside the destination and the next call continues from it rather
        than fetching the 2.2 GB again. The file is opened and queried before
        it is moved into place, so a damaged download never replaces a working
        database.

        Args:
            db_path (str, optional): Path where to save the database. If None, uses default
                                    location in the persistent user dataset directory.
            zenodo_url (str, optional): URL to download from. If None, uses default Zenodo URL.
                                       Format: https://zenodo.org/record/XXXXXX/files/pubchem_id.db
            force (bool): If True, overwrite an existing local database file.

        Returns:
            (str): Path to the downloaded database file

        Raises:
            FileExistsError: If the database exists and ``force`` is False.
            provesid.datasets.DownloadError: If the download could not be
                completed.
            RuntimeError: If the file that arrived is not the PubChem ID
                database.

        Examples:
            >>> from provesid import PubChemID
            >>> PubChemID.download_database(force=True)                  # doctest: +SKIP
            '/home/me/.local/share/provesid/pubchem_id.db'
            >>> PubChemID.download_database(db_path='/tmp/pubchem_id.db')  # doctest: +SKIP
            '/tmp/pubchem_id.db'

        Note:
            The database file is ~2.2 GB, so download may take several minutes.
        """
        logger = logging.getLogger(__name__)

        if db_path is None:
            db_path = os.path.join(
                user_dataset_path(),
                PubChemID.DEFAULT_DB_NAME,
            )
        else:
            db_path = os.path.abspath(os.path.expanduser(db_path))

        if os.path.exists(db_path) and not force:
            raise FileExistsError(
                f"Database already exists at: {db_path}. Use force=True to overwrite."
            )

        def must_be_the_compounds_database(path):
            """Reject a download that cannot answer the query this class asks."""
            connection = sqlite3.connect(path)
            try:
                count = connection.execute(
                    "SELECT COUNT(*) FROM compounds"
                ).fetchone()[0]
            except Exception as exc:
                raise RuntimeError(
                    f"Downloaded file is not a valid database: {exc}"
                ) from exc
            finally:
                connection.close()
            logger.info("Database verified: %s compounds", f"{count:,}")

        logger.info("This is a large file (~2.2 GB), please be patient.")
        return download_file(
            zenodo_url or PubChemID.DEFAULT_DB_URL,
            db_path,
            verify=must_be_the_compounds_database,
            description="PubChem ID database",
            log=logger,
        )

    def get_by_cid(self, cid: int) -> Optional[Dict[str, Any]]:
        """
        Get compound information by PubChem CID.

        Every other ``get_by_*`` method finds a CID and then returns this
        record for it.

        Args:
            cid (int): PubChem Compound ID

        Returns:
            (dict): Every column of the ``compounds`` row (``cid``, ``cmpdname``,
            ``mf``, ``inchi``, ``smiles``, ``inchikey``, ``iupacname``, ``mw``,
            ``exactmass``, ``cidcdate`` and whichever others the database has;
            see
            [`get_by_cas_batch`][provesid.pubchem_id.PubChemID.get_by_cas_batch]),
            plus ``cas_numbers``, the compound's distinct CAS numbers in
            database order, and ``synonyms``, at most 100 of its names. None if
            the CID is not in the database.

        Examples:
            >>> db = PubChemID()
            >>> result = db.get_by_cid(2244)  # Aspirin
            >>> result['cmpdname'], result['mf'], result['cas_numbers']
            ('Aspirin', 'C9H8O4', ['50-78-2'])
            >>> result['synonyms'][:2]
            ['aspirin', 'ACETYLSALICYLIC ACID']
            >>> db.get_by_cid(999999999) is None
            True
        """
        cursor = self.conn.cursor()
        cursor.execute("""
            SELECT * FROM compounds WHERE cid = ?
        """, (cid,))

        row = cursor.fetchone()
        if not row:
            return None

        result = dict(row)

        # Add CAS numbers
        # GROUP BY because the Zenodo copy repeats some (cid, cas) pairs ---
        # aspirin's CAS is listed twice. A database built from FTP does not.
        cursor.execute("""
            SELECT cas FROM cas_numbers WHERE cid = ?
            GROUP BY cas ORDER BY MIN(id)
        """, (cid,))
        result['cas_numbers'] = [r[0] for r in cursor.fetchall()]

        # Add synonyms
        cursor.execute("""
            SELECT synonym FROM synonyms WHERE cid = ? LIMIT 100
        """, (cid,))
        result['synonyms'] = [r[0] for r in cursor.fetchall()]

        return result

    def get_by_cas(self, cas: str) -> Optional[Dict[str, Any]]:
        """
        Get compound information by CAS Registry Number.

        Args:
            cas (str): CAS Registry Number (e.g., "50-78-2")

        Returns:
            (dict): The [`get_by_cid`][provesid.pubchem_id.PubChemID.get_by_cid]
                record, or None if not found. A CAS
            number PubChem gives to several compounds returns the first one.

        Examples:
            >>> db = PubChemID()
            >>> result = db.get_by_cas("50-78-2")  # Aspirin
            >>> print(result['inchi'])
            InChI=1S/C9H8O4/c1-6(10)13-8-5-3-2-4-7(8)9(11)12/h2-5H,1H3,(H,11,12)
            >>> db.get_by_cas("50782") is None       # the hyphens are required
            True
        """
        cursor = self.conn.cursor()
        cursor.execute("""
            SELECT cid FROM cas_numbers WHERE cas = ? LIMIT 1
        """, (cas,))

        row = cursor.fetchone()
        if not row:
            return None

        return self.get_by_cid(row[0])

    def get_by_inchikey(self, inchikey: str) -> Optional[Dict[str, Any]]:
        """
        Get compound information by InChIKey.

        Args:
            inchikey (str): Standard InChIKey (27 characters)

        Returns:
            (dict): The [`get_by_cid`][provesid.pubchem_id.PubChemID.get_by_cid]
                record, or None if not found.

        Examples:
            >>> db = PubChemID()
            >>> result = db.get_by_inchikey("BSYNRYMUTXBXSQ-UHFFFAOYSA-N")
            >>> print(result['cmpdname'])
            Aspirin
        """
        cursor = self.conn.cursor()
        cursor.execute("""
            SELECT * FROM compounds WHERE inchikey = ?
        """, (inchikey,))

        row = cursor.fetchone()
        if not row:
            return None

        cid = row['cid']
        return self.get_by_cid(cid)

    def get_by_inchi(self, inchi: str) -> Optional[Dict[str, Any]]:
        """
        Get compound information by InChI string.

        Args:
            inchi (str): Standard InChI string

        Returns:
            (dict): The [`get_by_cid`][provesid.pubchem_id.PubChemID.get_by_cid]
                record, or None if not found. The
            match is exact: a truncated or non-standard InChI finds nothing.

        Examples:
            >>> db = PubChemID()
            >>> inchi = "InChI=1S/C9H8O4/c1-6(10)13-8-5-3-2-4-7(8)9(11)12/h2-5H,1H3,(H,11,12)"
            >>> print(db.get_by_inchi(inchi)['cmpdname'])
            Aspirin
            >>> db.get_by_inchi("InChI=1S/C9H8O4/c1-6(10)") is None
            True
        """
        cursor = self.conn.cursor()
        cursor.execute("""
            SELECT * FROM compounds WHERE inchi = ?
        """, (inchi,))

        row = cursor.fetchone()
        if not row:
            return None

        cid = row['cid']
        return self.get_by_cid(cid)

    def get_by_smiles(self, smiles: str) -> Optional[Dict[str, Any]]:
        """
        Get compound information by SMILES string.

        The match is on the stored string, not the structure, so only
        PubChem's own SMILES for a compound finds it.
        [`smiles_to_cas`][provesid.pubchem_id.PubChemID.smiles_to_cas] compares
        structures instead.

        Args:
            smiles (str): SMILES string

        Returns:
            (dict): The [`get_by_cid`][provesid.pubchem_id.PubChemID.get_by_cid]
                record, or None if not found.

        Examples:
            >>> db = PubChemID()
            >>> result = db.get_by_smiles("CC(=O)OC1=CC=CC=C1C(=O)O")  # Aspirin
            >>> print(result['cmpdname'])
            Aspirin
            >>> db.get_by_smiles("CCO")['cid'], db.get_by_smiles("OCC")
            (702, None)
        """
        cursor = self.conn.cursor()
        cursor.execute("""
            SELECT * FROM compounds WHERE smiles = ?
        """, (smiles,))

        row = cursor.fetchone()
        if not row:
            return None

        cid = row['cid']
        return self.get_by_cid(cid)

    def search_by_name(self, name: str, exact: bool = False, limit: int = 10) -> List[Dict[str, Any]]:
        """
        Search compounds by name or synonym.

        Compound titles are searched first, then synonyms, until ``limit`` is
        reached. An exact match is case-sensitive: ``"Aspirin"`` is the title
        and ``"aspirin"`` a synonym, and both find CID 2244. A partial match
        is SQL ``LIKE``, case-insensitive for ASCII letters, and returns
        compounds in database order, not by closeness.

        Args:
            name (str): Compound name or synonym to search for
            exact (bool): If True, exact match only. If False, partial match (case-insensitive)
            limit (int): Maximum number of results to return

        Returns:
            (list): [`get_by_cid`][provesid.pubchem_id.PubChemID.get_by_cid]
                records, each compound once. Empty when
            nothing matches.

        Examples:
            >>> db = PubChemID()
            >>> for r in db.search_by_name("aspirin", limit=3):
            ...     print(r['cid'], r['cmpdname'])
            2244 Aspirin
            6247 Calcium aspirin
            21975 Carbaspirin Calcium
            >>> [r['cid'] for r in db.search_by_name("aspirin", exact=True)]
            [2244]
        """
        cursor = self.conn.cursor()

        results = []

        if exact:
            # Search in main compound name
            cursor.execute("""
                SELECT cid FROM compounds WHERE cmpdname = ? LIMIT ?
            """, (name, limit))

            cids = [r[0] for r in cursor.fetchall()]

            # Also search in synonyms
            if len(cids) < limit:
                cursor.execute("""
                    SELECT DISTINCT cid FROM synonyms WHERE synonym = ? LIMIT ?
                """, (name, limit - len(cids)))
                cids.extend([r[0] for r in cursor.fetchall()])
        else:
            # Partial match with LIKE
            search_term = f"%{name}%"

            # Search in main compound name
            cursor.execute("""
                SELECT cid FROM compounds WHERE cmpdname LIKE ? LIMIT ?
            """, (search_term, limit))

            cids = [r[0] for r in cursor.fetchall()]

            # Also search in synonyms
            if len(cids) < limit:
                cursor.execute("""
                    SELECT DISTINCT cid FROM synonyms WHERE synonym LIKE ? LIMIT ?
                """, (search_term, limit - len(cids)))
                cids.extend([r[0] for r in cursor.fetchall()])

        # A compound can match both its title and a synonym.
        cids = list(dict.fromkeys(cids))

        # Get full compound info for each CID
        for cid in cids[:limit]:
            compound = self.get_by_cid(cid)
            if compound:
                results.append(compound)

        return results

    def search_by_formula(self, formula: str, limit: int = 100) -> List[Dict[str, Any]]:
        """
        Search compounds by molecular formula.

        Args:
            formula (str): Molecular formula (e.g., "C9H8O4")
            limit (int): Maximum number of results to return

        Returns:
            (list): [`get_by_cid`][provesid.pubchem_id.PubChemID.get_by_cid]
                records for compounds whose formula is
            exactly ``formula``, in database order. The formula must be
            written as PubChem writes it (Hill order).

        Examples:
            >>> db = PubChemID()
            >>> results = db.search_by_formula("C9H8O4", limit=5)
            >>> len(results), all(r['mf'] == 'C9H8O4' for r in results)
            (5, True)
            >>> 'Aspirin' in [r['cmpdname'] for r in db.search_by_formula("C9H8O4")]
            True
        """
        cursor = self.conn.cursor()
        cursor.execute("""
            SELECT cid FROM compounds WHERE mf = ? LIMIT ?
        """, (formula, limit))

        results = []
        for row in cursor.fetchall():
            compound = self.get_by_cid(row[0])
            if compound:
                results.append(compound)

        return results

    # Conversion methods

    def cas_to_cid(self, cas: str) -> Optional[int]:
        """
        Convert CAS number to PubChem CID.

        Args:
            cas: CAS Registry Number, with hyphens.

        Returns:
            The CID, or None if the CAS number is not in the database.

        Examples:
            >>> db = PubChemID()
            >>> db.cas_to_cid("50-78-2")
            2244
        """
        result = self.get_by_cas(cas)
        return result['cid'] if result else None

    def cas_to_inchi(self, cas: str) -> Optional[str]:
        """
        Convert CAS number to InChI.

        Args:
            cas: CAS Registry Number, with hyphens.

        Returns:
            The standard InChI, or None if not found.

        Examples:
            >>> db = PubChemID()
            >>> db.cas_to_inchi("50-78-2")
            'InChI=1S/C9H8O4/c1-6(10)13-8-5-3-2-4-7(8)9(11)12/h2-5H,1H3,(H,11,12)'
        """
        result = self.get_by_cas(cas)
        return result['inchi'] if result else None

    def cas_to_inchikey(self, cas: str) -> Optional[str]:
        """
        Convert CAS number to InChIKey.

        Args:
            cas: CAS Registry Number, with hyphens.

        Returns:
            The standard InChIKey, or None if not found.

        Examples:
            >>> db = PubChemID()
            >>> db.cas_to_inchikey("50-78-2")
            'BSYNRYMUTXBXSQ-UHFFFAOYSA-N'
        """
        result = self.get_by_cas(cas)
        return result['inchikey'] if result else None

    def cas_to_smiles(self, cas: str) -> Optional[str]:
        """
        Convert CAS number to SMILES.

        Args:
            cas: CAS Registry Number, with hyphens.

        Returns:
            PubChem's isomeric SMILES, or None if not found.

        Examples:
            >>> db = PubChemID()
            >>> db.cas_to_smiles("50-78-2")
            'CC(=O)OC1=CC=CC=C1C(=O)O'
        """
        result = self.get_by_cas(cas)
        return result['smiles'] if result else None

    def inchikey_to_cid(self, inchikey: str) -> Optional[int]:
        """
        Convert InChIKey to PubChem CID.

        Args:
            inchikey: Standard InChIKey.

        Returns:
            The CID, or None if not found.

        Examples:
            >>> db = PubChemID()
            >>> db.inchikey_to_cid("BSYNRYMUTXBXSQ-UHFFFAOYSA-N")
            2244
        """
        result = self.get_by_inchikey(inchikey)
        return result['cid'] if result else None

    def inchikey_to_cas(self, inchikey: str) -> Optional[List[str]]:
        """
        Convert InChIKey to CAS number(s).

        Args:
            inchikey: Standard InChIKey.

        Returns:
            The compound's CAS numbers (possibly empty), or None if the InChIKey is not found.

        Examples:
            >>> db = PubChemID()
            >>> db.inchikey_to_cas("BSYNRYMUTXBXSQ-UHFFFAOYSA-N")
            ['50-78-2']
        """
        result = self.get_by_inchikey(inchikey)
        return result['cas_numbers'] if result else None

    def inchi_to_cid(self, inchi: str) -> Optional[int]:
        """
        Convert InChI to PubChem CID.

        Args:
            inchi: Standard InChI, matched exactly.

        Returns:
            The CID, or None if not found.

        Examples:
            >>> db = PubChemID()
            >>> db.inchi_to_cid("InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3")
            702
        """
        result = self.get_by_inchi(inchi)
        return result['cid'] if result else None

    def inchi_to_cas(self, inchi: str) -> Optional[List[str]]:
        """
        Convert InChI to CAS number(s).

        Args:
            inchi: Standard InChI, matched exactly.

        Returns:
            The compound's CAS numbers, or None if the InChI is not found.

        Examples:
            >>> db = PubChemID()
            >>> db.inchi_to_cas("InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3")
            ['64-17-5']
        """
        result = self.get_by_inchi(inchi)
        return result['cas_numbers'] if result else None

    def cid_to_cas(self, cid: int) -> Optional[List[str]]:
        """
        Convert PubChem CID to CAS number(s).

        Args:
            cid: PubChem Compound ID.

        Returns:
            The compound's distinct CAS numbers, or None if the CID is not found. A compound can have several: retired numbers, and numbers for mixtures PubChem maps to it.

        Examples:
            >>> db = PubChemID()
            >>> db.cid_to_cas(712)
            ['50-00-0', '30525-89-4', '53026-80-5', '8013-13-6', '12795-06-1']
        """
        result = self.get_by_cid(cid)
        return result['cas_numbers'] if result else None

    def cid_to_inchikey(self, cid: int) -> Optional[str]:
        """
        Convert PubChem CID to InChIKey.

        Args:
            cid: PubChem Compound ID.

        Returns:
            The standard InChIKey, or None if not found.

        Examples:
            >>> db = PubChemID()
            >>> db.cid_to_inchikey(2244)
            'BSYNRYMUTXBXSQ-UHFFFAOYSA-N'
        """
        result = self.get_by_cid(cid)
        return result['inchikey'] if result else None

    def cid_to_inchi(self, cid: int) -> Optional[str]:
        """
        Convert PubChem CID to InChI.

        Args:
            cid: PubChem Compound ID.

        Returns:
            The standard InChI, or None if not found.

        Examples:
            >>> db = PubChemID()
            >>> db.cid_to_inchi(702)
            'InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3'
        """
        result = self.get_by_cid(cid)
        return result['inchi'] if result else None

    def cid_to_smiles(self, cid: int) -> Optional[str]:
        """
        Convert PubChem CID to SMILES.

        Args:
            cid: PubChem Compound ID.

        Returns:
            PubChem's isomeric SMILES, or None if not found.

        Examples:
            >>> db = PubChemID()
            >>> db.cid_to_smiles(2244)
            'CC(=O)OC1=CC=CC=C1C(=O)O'
        """
        result = self.get_by_cid(cid)
        return result['smiles'] if result else None

    def smiles_to_cid(self, smiles: str) -> Optional[int]:
        """
        Convert SMILES string to PubChem CID.

        Args:
            smiles: SMILES, matched as a string against PubChem's; see
                [`get_by_smiles`][provesid.pubchem_id.PubChemID.get_by_smiles].

        Returns:
            The CID, or None if not found.

        Examples:
            >>> db = PubChemID()
            >>> db.smiles_to_cid("CCO")
            702
        """
        result = self.get_by_smiles(smiles)
        return result['cid'] if result else None

    # Batch conversion methods

    def batch_cas_to_cid(self, cas_list: List[str]) -> Dict[str, Optional[int]]:
        """
        Convert multiple CAS numbers to CIDs.

        Args:
            cas_list (list): List of CAS numbers

        Returns:
            (dict): Mapping of CAS -> CID (None if not found), in input order.

        Examples:
            >>> db = PubChemID()
            >>> results = db.batch_cas_to_cid(["50-78-2", "50-00-0"])
            >>> print(results)
            {'50-78-2': 2244, '50-00-0': 712}
        """
        results = {}
        for cas in cas_list:
            results[cas] = self.cas_to_cid(cas)
        return results

    def batch_cas_to_inchikey(self, cas_list: List[str]) -> Dict[str, Optional[str]]:
        """
        Convert multiple CAS numbers to InChIKeys.

        Args:
            cas_list (list): List of CAS numbers

        Returns:
            (dict): Mapping of CAS -> InChIKey (None if not found)

        Examples:
            >>> db = PubChemID()
            >>> db.batch_cas_to_inchikey(["50-78-2", "0-00-0"])
            {'50-78-2': 'BSYNRYMUTXBXSQ-UHFFFAOYSA-N', '0-00-0': None}
        """
        results = {}
        for cas in cas_list:
            results[cas] = self.cas_to_inchikey(cas)
        return results

    def batch_cid_to_cas(self, cid_list: List[int]) -> Dict[int, Optional[List[str]]]:
        """
        Convert multiple CIDs to CAS numbers.

        Args:
            cid_list (list): List of PubChem CIDs

        Returns:
            (dict): Mapping of CID -> list of CAS numbers (None if not found)

        Examples:
            >>> db = PubChemID()
            >>> db.batch_cid_to_cas([2244, 702])
            {2244: ['50-78-2'], 702: ['64-17-5']}
        """
        results = {}
        for cid in cid_list:
            results[cid] = self.cid_to_cas(cid)
        return results

    def batch_smiles_to_cid(self, smiles_list: List[str]) -> Dict[str, Optional[int]]:
        """
        Convert multiple SMILES strings to CIDs.

        Args:
            smiles_list (list): List of SMILES strings

        Returns:
            (dict): Mapping of SMILES -> CID (None if not found)

        Examples:
            >>> db = PubChemID()
            >>> results = db.batch_smiles_to_cid(["CC(=O)OC1=CC=CC=C1C(=O)O", "C"])
            >>> print(results)
            {'CC(=O)OC1=CC=CC=C1C(=O)O': 2244, 'C': 297}
        """
        results = {}
        for smiles in smiles_list:
            results[smiles] = self.smiles_to_cid(smiles)
        return results

    def get_by_cas_batch(self, cas_list: List[str]) -> 'pd.DataFrame':
        """
        Get complete compound information for multiple CAS numbers as a DataFrame.

        One row per CAS number found, carrying every column of the
        ``compounds`` table. Which columns those are depends on how the
        database was made: one built from PubChem's FTP site has
        ``monoisotopicmass``, a Zenodo copy has the eight descriptor columns
        instead (``xlogp``, ``polararea`` and the like).

        Args:
            cas_list (list): List of CAS Registry Numbers

        Returns:
            (pandas.DataFrame): ``cid``, ``cas`` and then the ``compounds``
            columns --- ``cmpdname``, ``mf``, ``inchi``, ``smiles``,
            ``inchikey``, ``iupacname``, ``mw``, ``exactmass``, ``cidcdate``
            and whichever others the database has. Empty, with those columns,
            when nothing is found.

        Examples:
            >>> db = PubChemID()
            >>> cas_list = ["50-78-2", "50-00-0", "64-17-5"]
            >>> df = db.get_by_cas_batch(cas_list)
            >>> print(df[['cas', 'cmpdname', 'mf', 'mw']])
                   cas      cmpdname      mf       mw
            0  50-78-2       Aspirin  C9H8O4  180.160
            1  50-00-0  Formaldehyde    CH2O   30.026
            2  64-17-5       Ethanol   C2H6O   46.070
        """
        rows = []
        for cas in cas_list:
            result = self.get_by_cas(cas)
            if result:
                rows.append({'cas': cas, **self._compound_columns(result)})
        return pd.DataFrame(rows, columns=['cid', 'cas'] + self._compound_column_names()[1:])

    def _compound_column_names(self) -> List[str]:
        """The ``compounds`` table's columns, in table order, ``cid`` first."""
        return [row[1] for row in self.conn.execute("PRAGMA table_info(compounds)")]

    def _compound_columns(self, record: Dict[str, Any]) -> Dict[str, Any]:
        """A [`get_by_cid`][provesid.pubchem_id.PubChemID.get_by_cid] record
        without its list-valued extras."""
        return {name: record.get(name) for name in self._compound_column_names()}

    def get_id_table_from_cas(self, cas: str) -> Optional['pd.DataFrame']:
        """
        Get identifier table for a CAS number (similar to ZeroPM format).

        Args:
            cas (str): CAS Registry Number

        Returns:
            (pandas.DataFrame): Table with columns [cid, cas, inchi, inchikey, smiles,
                             cmpdname, mf, mw] or None if not found

        Examples:
            >>> db = PubChemID()
            >>> df = db.get_id_table_from_cas("50-78-2")
            >>> df[['cid', 'cas', 'cmpdname', 'mf', 'mw']].to_dict('records')
            [{'cid': 2244, 'cas': '50-78-2', 'cmpdname': 'Aspirin', 'mf': 'C9H8O4', 'mw': 180.16}]
            >>> db.get_id_table_from_cas("0-00-0") is None
            True
        """
        import pandas as pd

        result = self.get_by_cas(cas)
        if not result:
            return None

        # Create DataFrame with main identifiers and properties
        df = pd.DataFrame([{
            'cid': result['cid'],
            'cas': cas,
            'inchi': result.get('inchi', ''),
            'inchikey': result.get('inchikey', ''),
            'smiles': result.get('smiles', ''),
            'cmpdname': result.get('cmpdname', ''),
            'mf': result.get('mf', ''),
            'mw': result.get('mw', None)
        }])

        return df

    def batch_get_id_table_from_cas(self, cas_list: List[str]) -> 'pd.DataFrame':
        """
        Get identifier tables for multiple CAS numbers.

        Args:
            cas_list (list): List of CAS Registry Numbers

        Returns:
            (pandas.DataFrame): One
            [`get_id_table_from_cas`][provesid.pubchem_id.PubChemID.get_id_table_from_cas]
            row per CAS number found; CAS numbers not found are left out.
            Empty, with the same columns, when none is found.

        Examples:
            >>> db = PubChemID()
            >>> df = db.batch_get_id_table_from_cas(["50-78-2", "0-00-0", "64-17-5"])
            >>> print(df[['cid', 'cas', 'cmpdname', 'mf']])
                cid      cas cmpdname      mf
            0  2244  50-78-2  Aspirin  C9H8O4
            1   702  64-17-5  Ethanol   C2H6O
        """
        import pandas as pd

        tables = []
        for cas in cas_list:
            df = self.get_id_table_from_cas(cas)
            if df is not None:
                tables.append(df)

        if not tables:
            # Return empty DataFrame with correct columns
            return pd.DataFrame(columns=['cid', 'cas', 'inchi', 'inchikey',
                                        'smiles', 'cmpdname', 'mf', 'mw'])

        return pd.concat(tables, ignore_index=True)

    def get_by_smiles_batch(self, smiles_list: List[str]) -> 'pd.DataFrame':
        """
        Get complete compound information for multiple SMILES strings as a DataFrame.

        One row per SMILES found, carrying the compound's first CAS number and
        every column of the ``compounds`` table; see
        [`get_by_cas_batch`][provesid.pubchem_id.PubChemID.get_by_cas_batch]
        for how those columns depend on where the database came from.

        Args:
            smiles_list (list): List of SMILES strings

        Returns:
            (pandas.DataFrame): ``cid``, ``cas`` and then the ``compounds``
            columns. Empty, with those columns, when nothing is found.

        Examples:
            >>> db = PubChemID()
            >>> smiles_list = ["CC(=O)OC1=CC=CC=C1C(=O)O", "C", "CCO"]
            >>> df = db.get_by_smiles_batch(smiles_list)
            >>> print(df[['smiles', 'cmpdname', 'mf', 'mw']])
                                 smiles cmpdname      mf       mw
            0  CC(=O)OC1=CC=CC=C1C(=O)O  Aspirin  C9H8O4  180.160
            1                         C  Methane     CH4   16.043
            2                       CCO  Ethanol   C2H6O   46.070
        """
        rows = []
        for smiles in smiles_list:
            result = self.get_by_smiles(smiles)
            if result:
                cas_numbers = result.get('cas_numbers') or [None]
                rows.append({'cas': cas_numbers[0], **self._compound_columns(result)})
        return pd.DataFrame(rows, columns=['cid', 'cas'] + self._compound_column_names()[1:])

    def smiles_to_cas(self, smiles: str) -> Optional[List[str]]:
        """
        Convert SMILES string to CAS number(s).

        Unlike [`smiles_to_cid`][provesid.pubchem_id.PubChemID.smiles_to_cid],
        this compares structures: the SMILES is converted to a standard InChI
        with RDKit and looked up by that, so any valid SMILES for the compound
        finds it.

        Args:
            smiles (str): SMILES string

        Returns:
            (list): List of CAS numbers, or None if not found, if RDKit cannot
            parse the SMILES, or if RDKit is not installed.

        Examples:
            >>> db = PubChemID()
            >>> db.smiles_to_cas("CC(=O)OC1=CC=CC=C1C(=O)O")  # Aspirin
            ['50-78-2']
            >>> db.smiles_to_cas("OCC"), db.smiles_to_cid("OCC")
            (['64-17-5'], None)
        """
        # First convert SMILES to InChI using RDKit
        try:
            from rdkit import Chem
            mol = Chem.MolFromSmiles(smiles)
            if mol is None:
                return None
            inchi = Chem.MolToInchi(mol)
        except Exception:
            return None

        # Then look up by InChI
        return self.inchi_to_cas(inchi)

    def name_to_cas(self, name: str, exact: bool = True) -> Optional[List[str]]:
        """
        Convert chemical name to CAS number(s).

        Args:
            name (str): Chemical name or synonym
            exact (bool): If True, exact match only. If False, returns first match from search.

        Returns:
            (list): The first matching compound's CAS numbers, or None if no
            compound matches. See
            [`search_by_name`][provesid.pubchem_id.PubChemID.search_by_name]
            for how names match.

        Examples:
            >>> db = PubChemID()
            >>> db.name_to_cas("aspirin")
            ['50-78-2']
            >>> db.name_to_cas("no such compound") is None
            True

        Note:
            For exact=False, only the first match from the search is returned.
            Use search_by_name() for more control over multiple matches.
        """
        results = self.search_by_name(name, exact=exact, limit=1)
        if not results:
            return None
        return results[0].get('cas_numbers')

    def formula_to_cas(self, formula: str, limit: int = 100) -> Optional[List[str]]:
        """
        Convert molecular formula to CAS numbers.

        Note: Molecular formulas are not unique - many isomers can share the same formula.
        This method returns CAS numbers for all compounds matching the formula.

        Args:
            formula (str): Molecular formula (e.g., "C9H8O4", "CH2O")
            limit (int): Maximum number of compounds to retrieve

        Returns:
            (list): The distinct CAS numbers of the first ``limit`` compounds
            with this formula, sorted as strings, or None if none is found

        Examples:
            >>> db = PubChemID()
            >>> cas_list = db.formula_to_cas("C9H8O4")
            >>> "50-78-2" in cas_list, cas_list == sorted(cas_list)
            (True, True)

        Warning:
            Can return many results for common formulas. Use limit parameter to control.
        """
        results = self.search_by_formula(formula, limit=limit)
        if not results:
            return None

        # Collect all unique CAS numbers from all matching compounds
        all_cas = []
        for compound in results:
            cas_numbers = compound.get('cas_numbers', [])
            if cas_numbers:
                all_cas.extend(cas_numbers)

        # Remove duplicates and sort
        unique_cas = sorted(set(all_cas))
        return unique_cas if unique_cas else None

    def batch_smiles_to_cas(self, smiles_list: List[str]) -> Dict[str, Optional[List[str]]]:
        """
        Convert multiple SMILES strings to CAS numbers.

        Args:
            smiles_list (list): List of SMILES strings

        Returns:
            (dict): Mapping of SMILES -> list of CAS numbers (None if not found)

        Examples:
            >>> db = PubChemID()
            >>> db.batch_smiles_to_cas(["OCC", "not a smiles"])
            {'OCC': ['64-17-5'], 'not a smiles': None}
        """
        return {smiles: self.smiles_to_cas(smiles) for smiles in smiles_list}

    def batch_name_to_cas(self, name_list: List[str], exact: bool = True) -> Dict[str, Optional[List[str]]]:
        """
        Convert multiple chemical names to CAS numbers.

        Args:
            name_list (list): List of chemical names
            exact (bool): If True, exact match only

        Returns:
            (dict): Mapping of name -> list of CAS numbers (None if not found)

        Examples:
            >>> db = PubChemID()
            >>> db.batch_name_to_cas(["aspirin", "ethanol", "xyzzy"])
            {'aspirin': ['50-78-2'], 'ethanol': ['64-17-5'], 'xyzzy': None}
        """
        return {name: self.name_to_cas(name, exact=exact) for name in name_list}

    def batch_formula_to_cas(self, formula_list: List[str], limit: int = 100) -> Dict[str, Optional[List[str]]]:
        """
        Convert multiple molecular formulas to CAS numbers.

        Args:
            formula_list (list): List of molecular formulas
            limit (int): Maximum number of compounds per formula

        Returns:
            (dict): Mapping of formula -> list of CAS numbers (None if not found)

        Examples:
            >>> db = PubChemID()
            >>> results = db.batch_formula_to_cas(["H2O", "CH4", "XeF9"])
            >>> "7732-18-5" in results["H2O"], "74-82-8" in results["CH4"], results["XeF9"]
            (True, True, None)
        """
        return {formula: self.formula_to_cas(formula, limit=limit) for formula in formula_list}

    @property
    def api(self) -> 'PubChemAPI':
        """
        The online client used to answer what the local database cannot.

        Created on first use rather than in ``__init__``, so a strictly offline
        session never builds one.

        Returns:
            The [`PubChemAPI`][provesid.pubchem.PubChemAPI] instance passed to
            ``__init__``, or one created with default settings.

        Examples:
            >>> from provesid import PubChemAPI
            >>> api = PubChemAPI()
            >>> PubChemID(api=api).api is api
            True
        """
        if self._api is None:
            self._api = PubChemAPI()
        return self._api

    def properties(self, cid: Union[int, str],
                   properties: Optional[List[str]] = None,
                   use_online_fallback: bool = True) -> Optional[Dict[str, Any]]:
        """
        Look up computed properties for one compound, offline first.

        The local database answers from disk in microseconds; the online API is
        consulted only when the local database cannot serve the request, either
        because it holds no row for this CID or because a requested property is
        not one of the columns it carries (see
        [`offline_properties`][provesid.pubchem_id.PubChemID]).

        Args:
            cid: PubChem Compound ID.
            properties: Property names to retrieve, e.g.
                ``['MolecularWeight', 'XLogP']``. Defaults to every property the
                local database can answer,
                [`offline_properties`][provesid.pubchem_id.PubChemID].
            use_online_fallback: When True (default), fall back to PUG-REST for
                anything the local database cannot answer. When False, the
                lookup is strictly offline, and a request the local database
                cannot answer in full --- an unknown CID, or any property
                outside [`offline_properties`][provesid.pubchem_id.PubChemID]
                --- returns None.

        Returns:
            A dict carrying ``CID``, a ``Source`` of ``'offline'`` or
            ``'online'``, and one key per property that has a value. A property
            the compound has no value for is omitted rather than set to None,
            which is how PubChem itself reports it — so ``'XLogP' not in
            result`` means PubChem computes no logP for this compound, not that
            the lookup fell short. Returns None when neither source knows the
            CID, or when ``use_online_fallback`` is False and the request needs
            the network.

        Raises:
            ValueError: If ``cid`` is not an integer, or ``properties`` is an
                empty list.
            PubChemError: If the online fallback was needed and its request
                could not be completed. An incomplete answer is never passed off
                as a complete one.

        Examples:
            >>> db = PubChemID()
            >>> db.properties(2244, ['MolecularFormula', 'MolecularWeight'])
            {'CID': 2244, 'Source': 'offline', 'MolecularFormula': 'C9H8O4', 'MolecularWeight': 180.16}
            >>> # XLogP is PubChem's model output, never served from disk
            >>> db.properties(2244, ['XLogP'])['Source']            # doctest: +SKIP
            'online'
            >>> db.properties(2244, ['XLogP'], use_online_fallback=False) is None
            True
        """
        rows = self.properties_for_cids([cid], properties,
                                        use_online_fallback=use_online_fallback)
        return rows[0] if rows else None

    def properties_for_cids(self, cids: List[Union[int, str]],
                            properties: Optional[List[str]] = None,
                            use_online_fallback: bool = True,
                            chunk_size: int = PROPERTY_CHUNK_SIZE) -> List[Dict[str, Any]]:
        """
        Look up computed properties for many compounds, offline first.

        Everything the local database can answer is read in a handful of SQL
        statements; only the remainder is requested from PubChem, in bulk, a few
        hundred compounds per request. A list of ten thousand CIDs that the
        local database covers therefore costs no network traffic at all.

        Args:
            cids: PubChem Compound IDs. Duplicates are collapsed and the order
                of first appearance is preserved.
            properties: Property names to retrieve. Defaults to
                [`offline_properties`][provesid.pubchem_id.PubChemID].
            use_online_fallback: When True (default), CIDs the local database
                does not cover are requested from PUG-REST.
            chunk_size: How many CIDs to put in one online request.

        Returns:
            One dict per CID that could be answered, in the order requested,
            each carrying ``CID``, a ``Source`` of ``'offline'`` or
            ``'online'``, and one key per property that has a value. CIDs
            neither source knows are omitted; use
            [`properties_table`][provesid.pubchem_id.PubChemID.properties_table]
            to get a row for every CID asked about.

        Raises:
            ValueError: If a CID is not an integer, ``properties`` is an empty
                list, or ``chunk_size`` is not positive.
            PubChemError: If an online request could not be completed.

        Note:
            If *any* requested property lies outside
            [`offline_properties`][provesid.pubchem_id.PubChemID], the whole
            request goes online: the missing property would need a request per
            compound anyway, so splitting the property list between the two
            sources would cost the same traffic and return rows assembled from
            two different PubChem snapshots.

        Examples:
            >>> db = PubChemID()
            >>> rows = db.properties_for_cids([2244, 702], ['MolecularFormula'])
            >>> for row in rows:
            ...     print(row['CID'], row['MolecularFormula'], row['Source'])
            2244 C9H8O4 offline
            702 C2H6O offline
        """
        if properties is not None and not properties:
            raise ValueError("properties must name at least one property, or be None")
        if chunk_size <= 0:
            raise ValueError(f"chunk_size must be positive, got {chunk_size}")

        requested_properties = list(properties) if properties else list(self.offline_properties)
        wanted_cids = [self._coerce_cid(cid) for cid in cids]
        wanted_cids = list(dict.fromkeys(wanted_cids))
        if not wanted_cids:
            return []

        online_only = [name for name in requested_properties
                       if name not in self.offline_properties]

        found: Dict[int, Dict[str, Any]] = {}
        if online_only:
            self.logger.debug(
                "Going straight online for %d CIDs: %s not in the local database",
                len(wanted_cids), ', '.join(online_only))
            missing = wanted_cids
        else:
            found = self._offline_properties(wanted_cids, requested_properties)
            missing = [cid for cid in wanted_cids if cid not in found]
            self.logger.debug("Served %d/%d CIDs offline", len(found), len(wanted_cids))

        if missing and use_online_fallback:
            self.logger.debug("Falling back online for %d CIDs", len(missing))
            found.update(self._online_properties(missing, requested_properties, chunk_size))

        return [found[cid] for cid in wanted_cids if cid in found]

    def properties_table(self, cids: List[Union[int, str]],
                         properties: Optional[List[str]] = None,
                         use_online_fallback: bool = True,
                         chunk_size: int = PROPERTY_CHUNK_SIZE) -> 'pd.DataFrame':
        """
        Offline-first property lookup for many compounds, as a DataFrame.

        Same lookup as
        [`properties_for_cids`][provesid.pubchem_id.PubChemID.properties_for_cids],
        reshaped so that every CID asked about has a row whether or not it
        could be answered. That makes the frame safe to concatenate or join
        against the caller's own table.

        Args:
            cids: PubChem Compound IDs. Duplicates are collapsed.
            properties: Property names to retrieve. Defaults to
                [`offline_properties`][provesid.pubchem_id.PubChemID].
            use_online_fallback: When True (default), consult PUG-REST for CIDs
                the local database does not cover.
            chunk_size: How many CIDs to put in one online request.

        Returns:
            A DataFrame with one row per distinct CID in the order requested.
            Columns are ``CID``, ``Source`` and the requested properties.
            ``Source`` reads ``'offline'``, ``'online'``, or ``'missing'`` for a
            CID neither source knows; a property with no value is NaN/None.

        Raises:
            ValueError: If a CID is not an integer, ``properties`` is an empty
                list, or ``chunk_size`` is not positive.
            PubChemError: If an online request could not be completed.

        Examples:
            >>> db = PubChemID()
            >>> table = db.properties_table([2244, 702], ['MolecularWeight'])
            >>> table[['CID', 'MolecularWeight', 'Source']].to_dict('records')
            [{'CID': 2244, 'MolecularWeight': 180.16, 'Source': 'offline'},
             {'CID': 702, 'MolecularWeight': 46.07, 'Source': 'offline'}]
        """
        requested_properties = list(properties) if properties else list(self.offline_properties)
        rows = self.properties_for_cids(cids, requested_properties,
                                        use_online_fallback=use_online_fallback,
                                        chunk_size=chunk_size)
        return self._table(cids, rows, requested_properties)

    def descriptors(self, cid: Union[int, str],
                    descriptors: Optional[List[str]] = None,
                    source: str = 'rdkit',
                    use_online_fallback: bool = True) -> Optional[Dict[str, Any]]:
        """
        Computed molecular descriptors for one compound, from RDKit or PubChem.

        The local database stores identifiers and structures, not descriptors:
        XLogP, TPSA and the counts are the output of a model run over the
        structure, and there is more than one model. This method runs one,
        and says which:

        * ``source="rdkit"`` (default) computes them with RDKit from the
          compound's stored SMILES --- no network, milliseconds. The record
          says ``Source='rdkit'``. RDKit and PubChem count some things
          differently, and the logP is a different model altogether, named
          ``MolLogP`` rather than ``XLogP``;
          [`rdkit_descriptors`][provesid.pubchem_id.rdkit_descriptors] measures
          how far apart they are. ``Complexity`` is not available.
        * ``source="pubchem"`` fetches PubChem's own values from PUG-REST,
          through the same path as
          [`properties`][provesid.pubchem_id.PubChemID.properties], labelled
          ``Source='online'``. This is the only way to PubChem's ``XLogP`` and
          ``Complexity``.

        Args:
            cid: PubChem Compound ID.
            descriptors: Names to compute. Defaults to every descriptor the
                source has:
                [`RDKIT_DESCRIPTORS`][provesid.pubchem_id.RDKIT_DESCRIPTORS] or
                [`PUBCHEM_DESCRIPTORS`][provesid.pubchem_id.PUBCHEM_DESCRIPTORS].
            source: ``'rdkit'`` or ``'pubchem'``.
            use_online_fallback: For ``source="rdkit"``, whether a compound the
                local database does not hold may have its SMILES fetched from
                PubChem to compute from. When False, such a compound returns
                None. ``source="pubchem"`` is online by definition and does not
                accept False.

        Returns:
            A dict carrying ``CID``, ``Source`` and one key per descriptor that
            has a value, or None when the compound is unknown. A compound whose
            SMILES RDKit cannot parse --- a handful in a million --- or that
            has no structure comes back with ``CID`` and ``Source`` only.

        Raises:
            ValueError: If ``cid`` is not an integer, ``source`` is unknown,
                a name is not one ``source`` provides (asking RDKit for
                ``XLogP`` says to ask for ``MolLogP``), or ``source="pubchem"``
                is combined with ``use_online_fallback=False``.
            PubChemError: If an online request could not be completed.

        Examples:
            >>> db = PubChemID()
            >>> db.descriptors(2244, ['MolLogP', 'TPSA'])
            {'CID': 2244, 'Source': 'rdkit', 'MolLogP': 1.3101, 'TPSA': 63.6}
            >>> db.descriptors(2244, ['XLogP'], source='pubchem')  # doctest: +SKIP
            {'CID': 2244, 'Source': 'online', 'XLogP': 1.2}
        """
        rows = self.descriptors_for_cids([cid], descriptors, source=source,
                                         use_online_fallback=use_online_fallback)
        return rows[0] if rows else None

    def descriptors_for_cids(self, cids: List[Union[int, str]],
                             descriptors: Optional[List[str]] = None,
                             source: str = 'rdkit',
                             use_online_fallback: bool = True,
                             chunk_size: int = PROPERTY_CHUNK_SIZE) -> List[Dict[str, Any]]:
        """
        Computed molecular descriptors for many compounds, from RDKit or PubChem.

        The list form of
        [`descriptors`][provesid.pubchem_id.PubChemID.descriptors]. With
        ``source="rdkit"`` the SMILES of every compound in the local database
        are read in a handful of statements, and only those it lacks are
        fetched from PubChem, in bulk; with ``source="pubchem"`` the whole list
        goes to PUG-REST a few hundred compounds per request.

        Args:
            cids: PubChem Compound IDs. Duplicates are collapsed and the order
                of first appearance is preserved.
            descriptors: Names to compute; defaults to every descriptor the
                source has.
            source: ``'rdkit'`` or ``'pubchem'``.
            use_online_fallback: For ``source="rdkit"``, whether SMILES missing
                from the local database may be fetched from PubChem.
            chunk_size: How many CIDs to put in one online request.

        Returns:
            One dict per CID that could be answered, in the order requested,
            shaped as
            [`descriptors`][provesid.pubchem_id.PubChemID.descriptors]
            describes. CIDs no source knows are omitted;
            [`descriptors_table`][provesid.pubchem_id.PubChemID.descriptors_table]
            gives a row for every CID.

        Raises:
            ValueError: As for
                [`descriptors`][provesid.pubchem_id.PubChemID.descriptors], or
                if ``chunk_size`` is not positive.
            PubChemError: If an online request could not be completed.

        Examples:
            >>> db = PubChemID()
            >>> for row in db.descriptors_for_cids([2244, 702], ['HeavyAtomCount']):
            ...     print(row)
            {'CID': 2244, 'Source': 'rdkit', 'HeavyAtomCount': 13}
            {'CID': 702, 'Source': 'rdkit', 'HeavyAtomCount': 3}
        """
        names = _check_descriptor_names(descriptors, source)

        if source == 'pubchem':
            if not use_online_fallback:
                raise ValueError("source='pubchem' fetches PubChem's values online; "
                                 "use_online_fallback=False contradicts it. For "
                                 "descriptors without the network use source='rdkit'.")
            return self.properties_for_cids(cids, names, chunk_size=chunk_size)

        structures = self.properties_for_cids(cids, ['SMILES'],
                                              use_online_fallback=use_online_fallback,
                                              chunk_size=chunk_size)
        rows = []
        for structure in structures:
            values = rdkit_descriptors(structure.get('SMILES'), names)
            if values is None:
                self.logger.debug("RDKit could not read the SMILES of CID %d: %r",
                                  structure['CID'], structure.get('SMILES'))
            rows.append({'CID': structure['CID'], 'Source': 'rdkit', **(values or {})})
        return rows

    def descriptors_table(self, cids: List[Union[int, str]],
                          descriptors: Optional[List[str]] = None,
                          source: str = 'rdkit',
                          use_online_fallback: bool = True,
                          chunk_size: int = PROPERTY_CHUNK_SIZE) -> 'pd.DataFrame':
        """
        Computed molecular descriptors for many compounds, as a DataFrame.

        Same lookup as
        [`descriptors_for_cids`][provesid.pubchem_id.PubChemID.descriptors_for_cids],
        with a row for every CID asked about, so the frame joins safely against
        the caller's own table. Because the RDKit and PubChem columns share
        names wherever the quantity is the same, two tables built with each
        source line up column for column, apart from ``MolLogP`` / ``XLogP``
        and ``Complexity``.

        Args:
            cids: PubChem Compound IDs. Duplicates are collapsed.
            descriptors: Names to compute; defaults to every descriptor the
                source has.
            source: ``'rdkit'`` or ``'pubchem'``.
            use_online_fallback: For ``source="rdkit"``, whether SMILES missing
                from the local database may be fetched from PubChem.
            chunk_size: How many CIDs to put in one online request.

        Returns:
            A DataFrame with one row per distinct CID in the order requested.
            Columns are ``CID``, ``Source`` and the descriptors. ``Source``
            reads ``'rdkit'``, ``'online'``, or ``'missing'`` for a CID no
            source knows; a descriptor with no value is NaN/None.

        Raises:
            ValueError: As for
                [`descriptors_for_cids`][provesid.pubchem_id.PubChemID.descriptors_for_cids].
            PubChemError: If an online request could not be completed.

        Examples:
            >>> db = PubChemID()
            >>> db.descriptors_table([2244, 702], ['TPSA'])
                CID Source   TPSA
            0  2244  rdkit  63.60
            1   702  rdkit  20.23
        """
        names = _check_descriptor_names(descriptors, source)
        rows = self.descriptors_for_cids(cids, names, source=source,
                                         use_online_fallback=use_online_fallback,
                                         chunk_size=chunk_size)
        return self._table(cids, rows, names)

    def _table(self, cids: List[Union[int, str]], rows: List[Dict[str, Any]],
               names: List[str]) -> 'pd.DataFrame':
        """
        Reshape looked-up records into a frame with a row for every CID asked about.

        Args:
            cids: The CIDs as the caller gave them; duplicates are collapsed.
            rows: Records carrying ``CID``, ``Source`` and values, as
                [`properties_for_cids`][provesid.pubchem_id.PubChemID.properties_for_cids]
                returns them.
            names: The value columns, in order.

        Returns:
            A DataFrame with columns ``CID``, ``Source`` and ``names``, where a
            CID with no record has ``Source='missing'``.
        """
        by_cid = {row['CID']: row for row in rows}
        records = []
        for cid in dict.fromkeys(self._coerce_cid(cid) for cid in cids):
            row = by_cid.get(cid, {'CID': cid, 'Source': 'missing'})
            records.append({'CID': cid, 'Source': row['Source'],
                            **{name: row.get(name) for name in names}})

        return pd.DataFrame(records, columns=['CID', 'Source'] + names)

    @staticmethod
    def _coerce_cid(cid: Union[int, str]) -> int:
        """
        Normalise a CID to an int so that ``2244`` and ``"2244"`` share a row.

        Args:
            cid: CID as an int or a string of digits.

        Returns:
            The CID as an int.

        Raises:
            ValueError: If ``cid`` is not an integer. PubChem answers a
                malformed CID with a blanket ``PUGREST.BadRequest`` that fails
                the whole batch, so it is worth catching here, where the
                offending value can be named.
        """
        try:
            return int(cid)
        except (TypeError, ValueError):
            raise ValueError(f"CID must be an integer, got {cid!r}")

    def _offline_properties(self, cids: List[int],
                            properties: List[str]) -> Dict[int, Dict[str, Any]]:
        """
        Read properties for the given CIDs from the local database.

        Args:
            cids: CIDs to look up, already coerced to int.
            properties: Property names, all of which must be keys of
                [`offline_properties`][provesid.pubchem_id.PubChemID].

        Returns:
            A dict keyed by CID, holding one record per CID present in the
            database. A record carries ``CID``, ``Source='offline'`` and the
            properties that have a value; a NULL column is left out, matching
            PubChem, which omits a property rather than reporting it as null.
        """
        columns = [self.offline_properties[name] for name in properties]
        cursor = self.conn.cursor()
        found: Dict[int, Dict[str, Any]] = {}

        # SQLite allows a limited number of bound parameters per statement
        # (999 by default), so the IN list is filled in batches.
        for start in range(0, len(cids), self._SQL_PARAMETER_LIMIT):
            batch = cids[start:start + self._SQL_PARAMETER_LIMIT]
            placeholders = ','.join('?' * len(batch))
            cursor.execute(
                f"SELECT cid, {', '.join(columns)} FROM compounds "
                f"WHERE cid IN ({placeholders})", batch)
            for row in cursor.fetchall():
                record = {'CID': row['cid'], 'Source': 'offline'}
                for name, column in zip(properties, columns):
                    value = row[column]
                    if value is not None and value != '':
                        record[name] = self._cast_property(name, value)
                found[row['cid']] = record

        return found

    def _online_properties(self, cids: List[int], properties: List[str],
                           chunk_size: int) -> Dict[int, Dict[str, Any]]:
        """
        Fetch properties for the given CIDs from PUG-REST.

        Args:
            cids: CIDs the local database could not answer.
            properties: Property names to request.
            chunk_size: How many CIDs to put in one request.

        Returns:
            A dict keyed by CID, holding one record per CID PubChem answered
            for, shaped like the offline records but with
            ``Source='online'``. PubChem returns a bare CID for a compound it
            has no record of; such a row is dropped, so the CID is reported as
            unknown rather than as a compound with no properties.

        Raises:
            PubChemError: If a request could not be completed.
        """
        rows = self.api.get_properties_for_cids(cids, properties, chunk_size=chunk_size)

        found: Dict[int, Dict[str, Any]] = {}
        for row in rows:
            values = {name: self._cast_property(name, row[name])
                      for name in properties
                      if row.get(name) is not None and row.get(name) != ''}
            if not values:
                continue
            cid = self._coerce_cid(row['CID'])
            found[cid] = {'CID': cid, 'Source': 'online', **values}

        return found

    @classmethod
    def _cast_property(cls, name: str, value: Any) -> Any:
        """
        Coerce a property value to one consistent type across both sources.

        The two sources disagree on types for the same property: PUG-REST
        returns ``MolecularWeight`` as the string ``"180.16"`` while the local
        database holds it as a float, and a table assembled from both sources
        has to be usable as one table.

        Args:
            name: Property name.
            value: Raw value from either source.

        Returns:
            The value cast to the type recorded in ``_PROPERTY_CASTS``, or
            unchanged when no cast is recorded or the cast does not apply. An
            uncastable value is returned as-is rather than discarded: a
            surprising value is more useful to the caller than a silent hole.
        """
        cast = cls._PROPERTY_CASTS.get(name)
        if cast is None:
            return value
        try:
            return cast(value)
        except (TypeError, ValueError):
            logging.debug("Could not cast %s=%r with %s", name, value, cast.__name__)
            return value

    def provenance(self) -> Dict[str, Any]:
        """
        Where this database came from and how it was built.

        A database built by
        [`provesid.pubchem_ftp.build_pubchem_id_db`][provesid.pubchem_ftp.build_pubchem_id_db]
        records its PubChem release, the snapshot's timestamp, the URL and MD5
        of every source file, the row counts and the build time. That is what
        makes a lookup against it citable: the release pins down exactly which
        state of PubChem answered.

        Returns:
            A dict of the ``provenance`` table's entries, plus ``files``: one
            dict per source file with ``file``, ``url``, ``md5``, ``bytes``,
            ``lines_read`` and ``rows_kept``. Empty for a database made before
            provenance was recorded --- every Zenodo copy so far.

        Examples:
            >>> db = PubChemID()                                  # doctest: +SKIP
            >>> db.provenance()["release"]                        # doctest: +SKIP
            '2026-09-01'
        """
        tables = {row[0] for row in self.conn.execute(
            "SELECT name FROM sqlite_master WHERE type = 'table'")}
        if "provenance" not in tables:
            return {}
        record: Dict[str, Any] = dict(
            self.conn.execute("SELECT key, value FROM provenance").fetchall())
        record["files"] = [dict(row) for row in self.conn.execute(
            "SELECT * FROM provenance_files ORDER BY rowid")]
        return record

    def xrefs(self, cid: Union[int, str]) -> Dict[str, List[str]]:
        """
        Identifiers other databases give this compound, as PubChem links them.

        PubChem publishes these links itself, in the same file the CAS
        numbers come from, so they cost nothing to keep: DSSTox substance IDs
        (``dtxsid``), ChEBI IDs, ChEMBL IDs, EC numbers and UNIIs --- see
        [`provesid.pubchem_ftp.XREF_TYPES`][provesid.pubchem_ftp.XREF_TYPES].

        Args:
            cid: PubChem Compound ID.

        Returns:
            A dict from source (``"dtxsid"``, ``"chebi"``, ``"chembl"``,
            ``"ec"``, ``"unii"``) to that source's identifiers for the
            compound, sorted. Sources with none are left out, so a compound
            with no links returns ``{}``.

        Raises:
            ValueError: If ``cid`` is not an integer.
            RuntimeError: If the database has no ``xrefs`` table --- a Zenodo
                copy. The message says how to build one that does.

        Examples:
            >>> db = PubChemID()                                  # doctest: +SKIP
            >>> db.xrefs(2244)                                    # doctest: +SKIP
            {'chebi': ['CHEBI:15365'], 'chembl': ['CHEMBL25'],
             'dtxsid': ['DTXSID5020108'], 'ec': ['200-064-1'],
             'unii': ['R16CO5Y76E']}
        """
        cid = self._coerce_cid(cid)
        tables = {row[0] for row in self.conn.execute(
            "SELECT name FROM sqlite_master WHERE type = 'table'")}
        if "xrefs" not in tables:
            raise RuntimeError(
                f"{self.db_path} has no cross-references. They exist only in a "
                "database built from PubChem's FTP site: "
                "provesid.pubchem_ftp.build_pubchem_id_db(force=True)."
            )
        found: Dict[str, List[str]] = {}
        for source, identifier in self.conn.execute(
                "SELECT source, identifier FROM xrefs WHERE cid = ? "
                "ORDER BY source, identifier", (cid,)):
            found.setdefault(source, []).append(identifier)
        return found

    def get_stats(self) -> Dict[str, int]:
        """
        Get database statistics.

        Returns:
            (dict): ``total_compounds``, ``total_cas_numbers`` (rows in the CAS
            table), ``compounds_with_cas``, ``total_synonyms``,
            ``compounds_with_inchikey``, ``database_path`` and
            ``database_size_mb``. The counts depend on the release.

        Examples:
            >>> db = PubChemID()
            >>> stats = db.get_stats()
            >>> print(f"Total compounds: {stats['total_compounds']:,}")  # doctest: +SKIP
            Total compounds: 1,589,910
            >>> stats['compounds_with_cas'] <= stats['total_compounds']
            True
        """
        cursor = self.conn.cursor()

        cursor.execute("SELECT COUNT(*) FROM compounds")
        total_compounds = cursor.fetchone()[0]

        cursor.execute("SELECT COUNT(*) FROM cas_numbers")
        total_cas = cursor.fetchone()[0]

        cursor.execute("SELECT COUNT(DISTINCT cid) FROM cas_numbers")
        compounds_with_cas = cursor.fetchone()[0]

        cursor.execute("SELECT COUNT(*) FROM synonyms")
        total_synonyms = cursor.fetchone()[0]

        cursor.execute("SELECT COUNT(*) FROM compounds WHERE inchikey IS NOT NULL AND inchikey != ''")
        compounds_with_inchikey = cursor.fetchone()[0]

        return {
            'total_compounds': total_compounds,
            'total_cas_numbers': total_cas,
            'compounds_with_cas': compounds_with_cas,
            'total_synonyms': total_synonyms,
            'compounds_with_inchikey': compounds_with_inchikey,
            'database_path': self.db_path,
            'database_size_mb': os.path.getsize(self.db_path) / (1024**2)
        }
Attributes
SOURCES class-attribute instance-attribute

Where a missing database comes from. "ftp" builds it from PubChem's FTP site (provesid.pubchem_ftp); "zenodo" downloads a prebuilt copy.

OFFLINE_PROPERTIES class-attribute instance-attribute

PubChem property names the local database can answer, mapped to their column in the compounds table. These are the properties that are data about a compound --- its identifiers, names, formula and masses. The computed descriptors (XLogP, TPSA, Complexity and the counts) are not served from disk even by a Zenodo database that still holds them: they are PubChem's model outputs, and a user who asks for them gets PubChem's current values, labelled Source='online', or RDKit's from descriptors, labelled Source='rdkit'. Note that smiles holds the isomeric SMILES, which is what PubChem now calls SMILES; the stereochemistry-free ConnectivitySMILES is not stored locally. MolecularWeight is computed from the formula when the database is built from FTP --- PubChem's files do not carry it --- and agrees with PubChem's to the second decimal for most compounds.

DEFAULT_PROPERTIES class-attribute instance-attribute

What properties retrieves when the caller names no properties, for a database that has every column. An open database uses offline_properties instead, so that a Zenodo copy without monoisotopicmass does not send every default lookup online.

api property

The online client used to answer what the local database cannot.

Created on first use rather than in __init__, so a strictly offline session never builds one.

Returns:

Type Description
PubChemAPI

The PubChemAPI instance passed to __init__, or one created with default settings.

Examples:

>>> from provesid import PubChemAPI
>>> api = PubChemAPI()
>>> PubChemID(api=api).api is api
True
Methods:
__init__(db_path=None, auto_download=True, data_dir=None, db_url=None, redownload=False, api=None, source='ftp')

Initialize PubChemID database connection.

Parameters:

Name Type Description Default
db_path str

Path to SQLite database. If None, uses default location in the persistent user dataset directory.

None
auto_download bool

If True, acquire the database from source when it is not on disk. Default is True.

True
data_dir str

Directory to store the database when db_path is not provided.

None
db_url str

Download URL for source="zenodo". If None, uses the package default URL.

None
redownload bool

If True, acquire the database again even though one is on disk, when auto_download is enabled. With source="ftp" that is a rebuild from the newest snapshot.

False
api PubChemAPI

Online client used by properties when the local database cannot answer a request. One is created on first use if none is given, so passing this is only needed to share a client or to configure its pause time.

None
source str

How a missing database is acquired, one of SOURCES. "ftp" (default) builds it from the newest monthly snapshot of PubChem's FTP site: 15.4 GB transferred, a 2.5 GB database plus 7.4 GB of free disk at peak, and about 12 minutes of processing on top of the download time. "zenodo" downloads a 2.2 GB prebuilt copy. It describes an acquisition, not a file: a database already on disk is opened whichever way it was made.

'ftp'

Raises:

Type Description
ValueError

If source is not one of SOURCES. Checked before anything is fetched.

FileNotFoundError

If database file doesn't exist and auto_download is False

Examples:

>>> db = PubChemID()                       # the default location
>>> db.source
'ftp'
>>> PubChemID(db_path="/no/such/pubchem_id.db", auto_download=False)
Traceback (most recent call last):
...
FileNotFoundError: PubChem ID database not found at /no/such/pubchem_id.db. ...
>>> PubChemID(source="ncbi")
Traceback (most recent call last):
...
ValueError: PubChemID(source='ncbi') is not a download route. Use one of 'ftp', 'zenodo'.
Source code in src/provesid/pubchem_id.py
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
def __init__(
    self,
    db_path: Optional[str] = None,
    auto_download: bool = True,
    data_dir: Optional[str] = None,
    db_url: Optional[str] = None,
    redownload: bool = False,
    api: Optional['PubChemAPI'] = None,
    source: str = "ftp",
):
    """
    Initialize PubChemID database connection.

    Args:
        db_path (str, optional): Path to SQLite database. If None, uses default
                                location in the persistent user dataset directory.
        auto_download (bool): If True, acquire the database from ``source``
            when it is not on disk. Default is True.
        data_dir (str, optional): Directory to store the database when
            ``db_path`` is not provided.
        db_url (str, optional): Download URL for ``source="zenodo"``. If
            None, uses the package default URL.
        redownload (bool): If True, acquire the database again even though
            one is on disk, when ``auto_download`` is enabled. With
            ``source="ftp"`` that is a rebuild from the newest snapshot.
        api (PubChemAPI, optional): Online client used by
            [`properties`][provesid.pubchem_id.PubChemID.properties] when
            the local database cannot answer a request. One is created on
            first use if none is given, so passing this is only needed to
            share a client or to configure its pause time.
        source (str): How a missing database is acquired, one of
            [`SOURCES`][provesid.pubchem_id.PubChemID.SOURCES]. ``"ftp"``
            (default) builds it from the newest monthly snapshot of
            PubChem's FTP site: 15.4 GB transferred, a 2.5 GB database plus
            7.4 GB of free disk at peak, and about 12 minutes of processing
            on top of the download time. ``"zenodo"`` downloads a 2.2 GB
            prebuilt copy. It describes an acquisition, not a file: a
            database already on disk is opened whichever way it was made.

    Raises:
        ValueError: If ``source`` is not one of
            [`SOURCES`][provesid.pubchem_id.PubChemID.SOURCES]. Checked
            before anything is fetched.
        FileNotFoundError: If database file doesn't exist and auto_download is False

    Examples:
        >>> db = PubChemID()                       # the default location
        >>> db.source
        'ftp'
        >>> PubChemID(db_path="/no/such/pubchem_id.db", auto_download=False)
        Traceback (most recent call last):
        ...
        FileNotFoundError: PubChem ID database not found at /no/such/pubchem_id.db. ...
        >>> PubChemID(source="ncbi")
        Traceback (most recent call last):
        ...
        ValueError: PubChemID(source='ncbi') is not a download route. Use one of 'ftp', 'zenodo'.
    """
    self.logger = logging.getLogger(__name__)
    self.source = self._validate_source(source)
    self.db_url = db_url or self.DEFAULT_DB_URL
    self._api = api

    if db_path is None:
        base_dir = data_dir or user_dataset_path()
        db_path = os.path.join(base_dir, self.DEFAULT_DB_NAME)

    self.db_path = os.path.abspath(os.path.expanduser(db_path))

    needs_download = redownload or not os.path.exists(self.db_path)

    if needs_download:
        if auto_download:
            if redownload and os.path.exists(self.db_path):
                self.logger.info(
                    "Forced PubChemID redownload requested for: %s", self.db_path
                )
            else:
                self.logger.info("Database not found at %s", self.db_path)
            self._acquire(force=redownload)
        else:
            raise FileNotFoundError(
                f"PubChem ID database not found at {self.db_path}. "
                "Set auto_download=True, run "
                "provesid.pubchem_ftp.build_pubchem_id_db(), or run "
                "PubChemID.download_database()."
            )

    # One connection per thread, released by close() or by leaving a
    # ``with`` block --- see
    # ``SQLiteClient``.
    self._open_database(self.db_path)
    self.offline_properties = self._available_offline_properties()
download_database(db_path=None, zenodo_url=None, force=False) staticmethod

Download PubChem ID database from Zenodo --- the source="zenodo" route.

To build it from PubChem's own files instead, which is the default route, see provesid.pubchem_ftp.build_pubchem_id_db.

The transfer is resumable: an interrupted download leaves a .part file beside the destination and the next call continues from it rather than fetching the 2.2 GB again. The file is opened and queried before it is moved into place, so a damaged download never replaces a working database.

Parameters:

Name Type Description Default
db_path str

Path where to save the database. If None, uses default location in the persistent user dataset directory.

None
zenodo_url str

URL to download from. If None, uses default Zenodo URL. Format: https://zenodo.org/record/XXXXXX/files/pubchem_id.db

None
force bool

If True, overwrite an existing local database file.

False

Returns:

Type Description
str

Path to the downloaded database file

Raises:

Type Description
FileExistsError

If the database exists and force is False.

DownloadError

If the download could not be completed.

RuntimeError

If the file that arrived is not the PubChem ID database.

Examples:

>>> from provesid import PubChemID
>>> PubChemID.download_database(force=True)
'/home/me/.local/share/provesid/pubchem_id.db'
>>> PubChemID.download_database(db_path='/tmp/pubchem_id.db')
'/tmp/pubchem_id.db'
Note

The database file is ~2.2 GB, so download may take several minutes.

Source code in src/provesid/pubchem_id.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
@staticmethod
def download_database(
    db_path: Optional[str] = None,
    zenodo_url: Optional[str] = None,
    force: bool = False,
) -> str:
    """
    Download PubChem ID database from Zenodo --- the ``source="zenodo"`` route.

    To build it from PubChem's own files instead, which is the default
    route, see
    [`provesid.pubchem_ftp.build_pubchem_id_db`][provesid.pubchem_ftp.build_pubchem_id_db].

    The transfer is resumable: an interrupted download leaves a ``.part``
    file beside the destination and the next call continues from it rather
    than fetching the 2.2 GB again. The file is opened and queried before
    it is moved into place, so a damaged download never replaces a working
    database.

    Args:
        db_path (str, optional): Path where to save the database. If None, uses default
                                location in the persistent user dataset directory.
        zenodo_url (str, optional): URL to download from. If None, uses default Zenodo URL.
                                   Format: https://zenodo.org/record/XXXXXX/files/pubchem_id.db
        force (bool): If True, overwrite an existing local database file.

    Returns:
        (str): Path to the downloaded database file

    Raises:
        FileExistsError: If the database exists and ``force`` is False.
        provesid.datasets.DownloadError: If the download could not be
            completed.
        RuntimeError: If the file that arrived is not the PubChem ID
            database.

    Examples:
        >>> from provesid import PubChemID
        >>> PubChemID.download_database(force=True)                  # doctest: +SKIP
        '/home/me/.local/share/provesid/pubchem_id.db'
        >>> PubChemID.download_database(db_path='/tmp/pubchem_id.db')  # doctest: +SKIP
        '/tmp/pubchem_id.db'

    Note:
        The database file is ~2.2 GB, so download may take several minutes.
    """
    logger = logging.getLogger(__name__)

    if db_path is None:
        db_path = os.path.join(
            user_dataset_path(),
            PubChemID.DEFAULT_DB_NAME,
        )
    else:
        db_path = os.path.abspath(os.path.expanduser(db_path))

    if os.path.exists(db_path) and not force:
        raise FileExistsError(
            f"Database already exists at: {db_path}. Use force=True to overwrite."
        )

    def must_be_the_compounds_database(path):
        """Reject a download that cannot answer the query this class asks."""
        connection = sqlite3.connect(path)
        try:
            count = connection.execute(
                "SELECT COUNT(*) FROM compounds"
            ).fetchone()[0]
        except Exception as exc:
            raise RuntimeError(
                f"Downloaded file is not a valid database: {exc}"
            ) from exc
        finally:
            connection.close()
        logger.info("Database verified: %s compounds", f"{count:,}")

    logger.info("This is a large file (~2.2 GB), please be patient.")
    return download_file(
        zenodo_url or PubChemID.DEFAULT_DB_URL,
        db_path,
        verify=must_be_the_compounds_database,
        description="PubChem ID database",
        log=logger,
    )
get_by_cid(cid)

Get compound information by PubChem CID.

Every other get_by_* method finds a CID and then returns this record for it.

Parameters:

Name Type Description Default
cid int

PubChem Compound ID

required

Returns:

Type Description
dict

Every column of the compounds row (cid, cmpdname, mf, inchi, smiles, inchikey, iupacname, mw, exactmass, cidcdate and whichever others the database has; see get_by_cas_batch), plus cas_numbers, the compound's distinct CAS numbers in database order, and synonyms, at most 100 of its names. None if the CID is not in the database.

Examples:

>>> db = PubChemID()
>>> result = db.get_by_cid(2244)  # Aspirin
>>> result['cmpdname'], result['mf'], result['cas_numbers']
('Aspirin', 'C9H8O4', ['50-78-2'])
>>> result['synonyms'][:2]
['aspirin', 'ACETYLSALICYLIC ACID']
>>> db.get_by_cid(999999999) is None
True
Source code in src/provesid/pubchem_id.py
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
def get_by_cid(self, cid: int) -> Optional[Dict[str, Any]]:
    """
    Get compound information by PubChem CID.

    Every other ``get_by_*`` method finds a CID and then returns this
    record for it.

    Args:
        cid (int): PubChem Compound ID

    Returns:
        (dict): Every column of the ``compounds`` row (``cid``, ``cmpdname``,
        ``mf``, ``inchi``, ``smiles``, ``inchikey``, ``iupacname``, ``mw``,
        ``exactmass``, ``cidcdate`` and whichever others the database has;
        see
        [`get_by_cas_batch`][provesid.pubchem_id.PubChemID.get_by_cas_batch]),
        plus ``cas_numbers``, the compound's distinct CAS numbers in
        database order, and ``synonyms``, at most 100 of its names. None if
        the CID is not in the database.

    Examples:
        >>> db = PubChemID()
        >>> result = db.get_by_cid(2244)  # Aspirin
        >>> result['cmpdname'], result['mf'], result['cas_numbers']
        ('Aspirin', 'C9H8O4', ['50-78-2'])
        >>> result['synonyms'][:2]
        ['aspirin', 'ACETYLSALICYLIC ACID']
        >>> db.get_by_cid(999999999) is None
        True
    """
    cursor = self.conn.cursor()
    cursor.execute("""
        SELECT * FROM compounds WHERE cid = ?
    """, (cid,))

    row = cursor.fetchone()
    if not row:
        return None

    result = dict(row)

    # Add CAS numbers
    # GROUP BY because the Zenodo copy repeats some (cid, cas) pairs ---
    # aspirin's CAS is listed twice. A database built from FTP does not.
    cursor.execute("""
        SELECT cas FROM cas_numbers WHERE cid = ?
        GROUP BY cas ORDER BY MIN(id)
    """, (cid,))
    result['cas_numbers'] = [r[0] for r in cursor.fetchall()]

    # Add synonyms
    cursor.execute("""
        SELECT synonym FROM synonyms WHERE cid = ? LIMIT 100
    """, (cid,))
    result['synonyms'] = [r[0] for r in cursor.fetchall()]

    return result
get_by_cas(cas)

Get compound information by CAS Registry Number.

Parameters:

Name Type Description Default
cas str

CAS Registry Number (e.g., "50-78-2")

required

Returns:

Type Description
dict

The get_by_cid record, or None if not found. A CAS number PubChem gives to several compounds returns the first one.

Examples:

>>> db = PubChemID()
>>> result = db.get_by_cas("50-78-2")  # Aspirin
>>> print(result['inchi'])
InChI=1S/C9H8O4/c1-6(10)13-8-5-3-2-4-7(8)9(11)12/h2-5H,1H3,(H,11,12)
>>> db.get_by_cas("50782") is None       # the hyphens are required
True
Source code in src/provesid/pubchem_id.py
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
def get_by_cas(self, cas: str) -> Optional[Dict[str, Any]]:
    """
    Get compound information by CAS Registry Number.

    Args:
        cas (str): CAS Registry Number (e.g., "50-78-2")

    Returns:
        (dict): The [`get_by_cid`][provesid.pubchem_id.PubChemID.get_by_cid]
            record, or None if not found. A CAS
        number PubChem gives to several compounds returns the first one.

    Examples:
        >>> db = PubChemID()
        >>> result = db.get_by_cas("50-78-2")  # Aspirin
        >>> print(result['inchi'])
        InChI=1S/C9H8O4/c1-6(10)13-8-5-3-2-4-7(8)9(11)12/h2-5H,1H3,(H,11,12)
        >>> db.get_by_cas("50782") is None       # the hyphens are required
        True
    """
    cursor = self.conn.cursor()
    cursor.execute("""
        SELECT cid FROM cas_numbers WHERE cas = ? LIMIT 1
    """, (cas,))

    row = cursor.fetchone()
    if not row:
        return None

    return self.get_by_cid(row[0])
get_by_inchikey(inchikey)

Get compound information by InChIKey.

Parameters:

Name Type Description Default
inchikey str

Standard InChIKey (27 characters)

required

Returns:

Type Description
dict

The get_by_cid record, or None if not found.

Examples:

>>> db = PubChemID()
>>> result = db.get_by_inchikey("BSYNRYMUTXBXSQ-UHFFFAOYSA-N")
>>> print(result['cmpdname'])
Aspirin
Source code in src/provesid/pubchem_id.py
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
def get_by_inchikey(self, inchikey: str) -> Optional[Dict[str, Any]]:
    """
    Get compound information by InChIKey.

    Args:
        inchikey (str): Standard InChIKey (27 characters)

    Returns:
        (dict): The [`get_by_cid`][provesid.pubchem_id.PubChemID.get_by_cid]
            record, or None if not found.

    Examples:
        >>> db = PubChemID()
        >>> result = db.get_by_inchikey("BSYNRYMUTXBXSQ-UHFFFAOYSA-N")
        >>> print(result['cmpdname'])
        Aspirin
    """
    cursor = self.conn.cursor()
    cursor.execute("""
        SELECT * FROM compounds WHERE inchikey = ?
    """, (inchikey,))

    row = cursor.fetchone()
    if not row:
        return None

    cid = row['cid']
    return self.get_by_cid(cid)
get_by_inchi(inchi)

Get compound information by InChI string.

Parameters:

Name Type Description Default
inchi str

Standard InChI string

required

Returns:

Type Description
dict

The get_by_cid record, or None if not found. The match is exact: a truncated or non-standard InChI finds nothing.

Examples:

>>> db = PubChemID()
>>> inchi = "InChI=1S/C9H8O4/c1-6(10)13-8-5-3-2-4-7(8)9(11)12/h2-5H,1H3,(H,11,12)"
>>> print(db.get_by_inchi(inchi)['cmpdname'])
Aspirin
>>> db.get_by_inchi("InChI=1S/C9H8O4/c1-6(10)") is None
True
Source code in src/provesid/pubchem_id.py
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
def get_by_inchi(self, inchi: str) -> Optional[Dict[str, Any]]:
    """
    Get compound information by InChI string.

    Args:
        inchi (str): Standard InChI string

    Returns:
        (dict): The [`get_by_cid`][provesid.pubchem_id.PubChemID.get_by_cid]
            record, or None if not found. The
        match is exact: a truncated or non-standard InChI finds nothing.

    Examples:
        >>> db = PubChemID()
        >>> inchi = "InChI=1S/C9H8O4/c1-6(10)13-8-5-3-2-4-7(8)9(11)12/h2-5H,1H3,(H,11,12)"
        >>> print(db.get_by_inchi(inchi)['cmpdname'])
        Aspirin
        >>> db.get_by_inchi("InChI=1S/C9H8O4/c1-6(10)") is None
        True
    """
    cursor = self.conn.cursor()
    cursor.execute("""
        SELECT * FROM compounds WHERE inchi = ?
    """, (inchi,))

    row = cursor.fetchone()
    if not row:
        return None

    cid = row['cid']
    return self.get_by_cid(cid)
get_by_smiles(smiles)

Get compound information by SMILES string.

The match is on the stored string, not the structure, so only PubChem's own SMILES for a compound finds it. smiles_to_cas compares structures instead.

Parameters:

Name Type Description Default
smiles str

SMILES string

required

Returns:

Type Description
dict

The get_by_cid record, or None if not found.

Examples:

>>> db = PubChemID()
>>> result = db.get_by_smiles("CC(=O)OC1=CC=CC=C1C(=O)O")  # Aspirin
>>> print(result['cmpdname'])
Aspirin
>>> db.get_by_smiles("CCO")['cid'], db.get_by_smiles("OCC")
(702, None)
Source code in src/provesid/pubchem_id.py
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
def get_by_smiles(self, smiles: str) -> Optional[Dict[str, Any]]:
    """
    Get compound information by SMILES string.

    The match is on the stored string, not the structure, so only
    PubChem's own SMILES for a compound finds it.
    [`smiles_to_cas`][provesid.pubchem_id.PubChemID.smiles_to_cas] compares
    structures instead.

    Args:
        smiles (str): SMILES string

    Returns:
        (dict): The [`get_by_cid`][provesid.pubchem_id.PubChemID.get_by_cid]
            record, or None if not found.

    Examples:
        >>> db = PubChemID()
        >>> result = db.get_by_smiles("CC(=O)OC1=CC=CC=C1C(=O)O")  # Aspirin
        >>> print(result['cmpdname'])
        Aspirin
        >>> db.get_by_smiles("CCO")['cid'], db.get_by_smiles("OCC")
        (702, None)
    """
    cursor = self.conn.cursor()
    cursor.execute("""
        SELECT * FROM compounds WHERE smiles = ?
    """, (smiles,))

    row = cursor.fetchone()
    if not row:
        return None

    cid = row['cid']
    return self.get_by_cid(cid)
search_by_name(name, exact=False, limit=10)

Search compounds by name or synonym.

Compound titles are searched first, then synonyms, until limit is reached. An exact match is case-sensitive: "Aspirin" is the title and "aspirin" a synonym, and both find CID 2244. A partial match is SQL LIKE, case-insensitive for ASCII letters, and returns compounds in database order, not by closeness.

Parameters:

Name Type Description Default
name str

Compound name or synonym to search for

required
exact bool

If True, exact match only. If False, partial match (case-insensitive)

False
limit int

Maximum number of results to return

10

Returns:

Type Description
list

get_by_cid records, each compound once. Empty when nothing matches.

Examples:

>>> db = PubChemID()
>>> for r in db.search_by_name("aspirin", limit=3):
...     print(r['cid'], r['cmpdname'])
2244 Aspirin
6247 Calcium aspirin
21975 Carbaspirin Calcium
>>> [r['cid'] for r in db.search_by_name("aspirin", exact=True)]
[2244]
Source code in src/provesid/pubchem_id.py
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
def search_by_name(self, name: str, exact: bool = False, limit: int = 10) -> List[Dict[str, Any]]:
    """
    Search compounds by name or synonym.

    Compound titles are searched first, then synonyms, until ``limit`` is
    reached. An exact match is case-sensitive: ``"Aspirin"`` is the title
    and ``"aspirin"`` a synonym, and both find CID 2244. A partial match
    is SQL ``LIKE``, case-insensitive for ASCII letters, and returns
    compounds in database order, not by closeness.

    Args:
        name (str): Compound name or synonym to search for
        exact (bool): If True, exact match only. If False, partial match (case-insensitive)
        limit (int): Maximum number of results to return

    Returns:
        (list): [`get_by_cid`][provesid.pubchem_id.PubChemID.get_by_cid]
            records, each compound once. Empty when
        nothing matches.

    Examples:
        >>> db = PubChemID()
        >>> for r in db.search_by_name("aspirin", limit=3):
        ...     print(r['cid'], r['cmpdname'])
        2244 Aspirin
        6247 Calcium aspirin
        21975 Carbaspirin Calcium
        >>> [r['cid'] for r in db.search_by_name("aspirin", exact=True)]
        [2244]
    """
    cursor = self.conn.cursor()

    results = []

    if exact:
        # Search in main compound name
        cursor.execute("""
            SELECT cid FROM compounds WHERE cmpdname = ? LIMIT ?
        """, (name, limit))

        cids = [r[0] for r in cursor.fetchall()]

        # Also search in synonyms
        if len(cids) < limit:
            cursor.execute("""
                SELECT DISTINCT cid FROM synonyms WHERE synonym = ? LIMIT ?
            """, (name, limit - len(cids)))
            cids.extend([r[0] for r in cursor.fetchall()])
    else:
        # Partial match with LIKE
        search_term = f"%{name}%"

        # Search in main compound name
        cursor.execute("""
            SELECT cid FROM compounds WHERE cmpdname LIKE ? LIMIT ?
        """, (search_term, limit))

        cids = [r[0] for r in cursor.fetchall()]

        # Also search in synonyms
        if len(cids) < limit:
            cursor.execute("""
                SELECT DISTINCT cid FROM synonyms WHERE synonym LIKE ? LIMIT ?
            """, (search_term, limit - len(cids)))
            cids.extend([r[0] for r in cursor.fetchall()])

    # A compound can match both its title and a synonym.
    cids = list(dict.fromkeys(cids))

    # Get full compound info for each CID
    for cid in cids[:limit]:
        compound = self.get_by_cid(cid)
        if compound:
            results.append(compound)

    return results
search_by_formula(formula, limit=100)

Search compounds by molecular formula.

Parameters:

Name Type Description Default
formula str

Molecular formula (e.g., "C9H8O4")

required
limit int

Maximum number of results to return

100

Returns:

Type Description
list

get_by_cid records for compounds whose formula is exactly formula, in database order. The formula must be written as PubChem writes it (Hill order).

Examples:

>>> db = PubChemID()
>>> results = db.search_by_formula("C9H8O4", limit=5)
>>> len(results), all(r['mf'] == 'C9H8O4' for r in results)
(5, True)
>>> 'Aspirin' in [r['cmpdname'] for r in db.search_by_formula("C9H8O4")]
True
Source code in src/provesid/pubchem_id.py
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
def search_by_formula(self, formula: str, limit: int = 100) -> List[Dict[str, Any]]:
    """
    Search compounds by molecular formula.

    Args:
        formula (str): Molecular formula (e.g., "C9H8O4")
        limit (int): Maximum number of results to return

    Returns:
        (list): [`get_by_cid`][provesid.pubchem_id.PubChemID.get_by_cid]
            records for compounds whose formula is
        exactly ``formula``, in database order. The formula must be
        written as PubChem writes it (Hill order).

    Examples:
        >>> db = PubChemID()
        >>> results = db.search_by_formula("C9H8O4", limit=5)
        >>> len(results), all(r['mf'] == 'C9H8O4' for r in results)
        (5, True)
        >>> 'Aspirin' in [r['cmpdname'] for r in db.search_by_formula("C9H8O4")]
        True
    """
    cursor = self.conn.cursor()
    cursor.execute("""
        SELECT cid FROM compounds WHERE mf = ? LIMIT ?
    """, (formula, limit))

    results = []
    for row in cursor.fetchall():
        compound = self.get_by_cid(row[0])
        if compound:
            results.append(compound)

    return results
cas_to_cid(cas)

Convert CAS number to PubChem CID.

Parameters:

Name Type Description Default
cas str

CAS Registry Number, with hyphens.

required

Returns:

Type Description
Optional[int]

The CID, or None if the CAS number is not in the database.

Examples:

>>> db = PubChemID()
>>> db.cas_to_cid("50-78-2")
2244
Source code in src/provesid/pubchem_id.py
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
def cas_to_cid(self, cas: str) -> Optional[int]:
    """
    Convert CAS number to PubChem CID.

    Args:
        cas: CAS Registry Number, with hyphens.

    Returns:
        The CID, or None if the CAS number is not in the database.

    Examples:
        >>> db = PubChemID()
        >>> db.cas_to_cid("50-78-2")
        2244
    """
    result = self.get_by_cas(cas)
    return result['cid'] if result else None
cas_to_inchi(cas)

Convert CAS number to InChI.

Parameters:

Name Type Description Default
cas str

CAS Registry Number, with hyphens.

required

Returns:

Type Description
Optional[str]

The standard InChI, or None if not found.

Examples:

>>> db = PubChemID()
>>> db.cas_to_inchi("50-78-2")
'InChI=1S/C9H8O4/c1-6(10)13-8-5-3-2-4-7(8)9(11)12/h2-5H,1H3,(H,11,12)'
Source code in src/provesid/pubchem_id.py
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
def cas_to_inchi(self, cas: str) -> Optional[str]:
    """
    Convert CAS number to InChI.

    Args:
        cas: CAS Registry Number, with hyphens.

    Returns:
        The standard InChI, or None if not found.

    Examples:
        >>> db = PubChemID()
        >>> db.cas_to_inchi("50-78-2")
        'InChI=1S/C9H8O4/c1-6(10)13-8-5-3-2-4-7(8)9(11)12/h2-5H,1H3,(H,11,12)'
    """
    result = self.get_by_cas(cas)
    return result['inchi'] if result else None
cas_to_inchikey(cas)

Convert CAS number to InChIKey.

Parameters:

Name Type Description Default
cas str

CAS Registry Number, with hyphens.

required

Returns:

Type Description
Optional[str]

The standard InChIKey, or None if not found.

Examples:

>>> db = PubChemID()
>>> db.cas_to_inchikey("50-78-2")
'BSYNRYMUTXBXSQ-UHFFFAOYSA-N'
Source code in src/provesid/pubchem_id.py
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
def cas_to_inchikey(self, cas: str) -> Optional[str]:
    """
    Convert CAS number to InChIKey.

    Args:
        cas: CAS Registry Number, with hyphens.

    Returns:
        The standard InChIKey, or None if not found.

    Examples:
        >>> db = PubChemID()
        >>> db.cas_to_inchikey("50-78-2")
        'BSYNRYMUTXBXSQ-UHFFFAOYSA-N'
    """
    result = self.get_by_cas(cas)
    return result['inchikey'] if result else None
cas_to_smiles(cas)

Convert CAS number to SMILES.

Parameters:

Name Type Description Default
cas str

CAS Registry Number, with hyphens.

required

Returns:

Type Description
Optional[str]

PubChem's isomeric SMILES, or None if not found.

Examples:

>>> db = PubChemID()
>>> db.cas_to_smiles("50-78-2")
'CC(=O)OC1=CC=CC=C1C(=O)O'
Source code in src/provesid/pubchem_id.py
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
def cas_to_smiles(self, cas: str) -> Optional[str]:
    """
    Convert CAS number to SMILES.

    Args:
        cas: CAS Registry Number, with hyphens.

    Returns:
        PubChem's isomeric SMILES, or None if not found.

    Examples:
        >>> db = PubChemID()
        >>> db.cas_to_smiles("50-78-2")
        'CC(=O)OC1=CC=CC=C1C(=O)O'
    """
    result = self.get_by_cas(cas)
    return result['smiles'] if result else None
inchikey_to_cid(inchikey)

Convert InChIKey to PubChem CID.

Parameters:

Name Type Description Default
inchikey str

Standard InChIKey.

required

Returns:

Type Description
Optional[int]

The CID, or None if not found.

Examples:

>>> db = PubChemID()
>>> db.inchikey_to_cid("BSYNRYMUTXBXSQ-UHFFFAOYSA-N")
2244
Source code in src/provesid/pubchem_id.py
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
def inchikey_to_cid(self, inchikey: str) -> Optional[int]:
    """
    Convert InChIKey to PubChem CID.

    Args:
        inchikey: Standard InChIKey.

    Returns:
        The CID, or None if not found.

    Examples:
        >>> db = PubChemID()
        >>> db.inchikey_to_cid("BSYNRYMUTXBXSQ-UHFFFAOYSA-N")
        2244
    """
    result = self.get_by_inchikey(inchikey)
    return result['cid'] if result else None
inchikey_to_cas(inchikey)

Convert InChIKey to CAS number(s).

Parameters:

Name Type Description Default
inchikey str

Standard InChIKey.

required

Returns:

Type Description
Optional[List[str]]

The compound's CAS numbers (possibly empty), or None if the InChIKey is not found.

Examples:

>>> db = PubChemID()
>>> db.inchikey_to_cas("BSYNRYMUTXBXSQ-UHFFFAOYSA-N")
['50-78-2']
Source code in src/provesid/pubchem_id.py
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
def inchikey_to_cas(self, inchikey: str) -> Optional[List[str]]:
    """
    Convert InChIKey to CAS number(s).

    Args:
        inchikey: Standard InChIKey.

    Returns:
        The compound's CAS numbers (possibly empty), or None if the InChIKey is not found.

    Examples:
        >>> db = PubChemID()
        >>> db.inchikey_to_cas("BSYNRYMUTXBXSQ-UHFFFAOYSA-N")
        ['50-78-2']
    """
    result = self.get_by_inchikey(inchikey)
    return result['cas_numbers'] if result else None
inchi_to_cid(inchi)

Convert InChI to PubChem CID.

Parameters:

Name Type Description Default
inchi str

Standard InChI, matched exactly.

required

Returns:

Type Description
Optional[int]

The CID, or None if not found.

Examples:

>>> db = PubChemID()
>>> db.inchi_to_cid("InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3")
702
Source code in src/provesid/pubchem_id.py
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
def inchi_to_cid(self, inchi: str) -> Optional[int]:
    """
    Convert InChI to PubChem CID.

    Args:
        inchi: Standard InChI, matched exactly.

    Returns:
        The CID, or None if not found.

    Examples:
        >>> db = PubChemID()
        >>> db.inchi_to_cid("InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3")
        702
    """
    result = self.get_by_inchi(inchi)
    return result['cid'] if result else None
inchi_to_cas(inchi)

Convert InChI to CAS number(s).

Parameters:

Name Type Description Default
inchi str

Standard InChI, matched exactly.

required

Returns:

Type Description
Optional[List[str]]

The compound's CAS numbers, or None if the InChI is not found.

Examples:

>>> db = PubChemID()
>>> db.inchi_to_cas("InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3")
['64-17-5']
Source code in src/provesid/pubchem_id.py
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
def inchi_to_cas(self, inchi: str) -> Optional[List[str]]:
    """
    Convert InChI to CAS number(s).

    Args:
        inchi: Standard InChI, matched exactly.

    Returns:
        The compound's CAS numbers, or None if the InChI is not found.

    Examples:
        >>> db = PubChemID()
        >>> db.inchi_to_cas("InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3")
        ['64-17-5']
    """
    result = self.get_by_inchi(inchi)
    return result['cas_numbers'] if result else None
cid_to_cas(cid)

Convert PubChem CID to CAS number(s).

Parameters:

Name Type Description Default
cid int

PubChem Compound ID.

required

Returns:

Type Description
Optional[List[str]]

The compound's distinct CAS numbers, or None if the CID is not found. A compound can have several: retired numbers, and numbers for mixtures PubChem maps to it.

Examples:

>>> db = PubChemID()
>>> db.cid_to_cas(712)
['50-00-0', '30525-89-4', '53026-80-5', '8013-13-6', '12795-06-1']
Source code in src/provesid/pubchem_id.py
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
def cid_to_cas(self, cid: int) -> Optional[List[str]]:
    """
    Convert PubChem CID to CAS number(s).

    Args:
        cid: PubChem Compound ID.

    Returns:
        The compound's distinct CAS numbers, or None if the CID is not found. A compound can have several: retired numbers, and numbers for mixtures PubChem maps to it.

    Examples:
        >>> db = PubChemID()
        >>> db.cid_to_cas(712)
        ['50-00-0', '30525-89-4', '53026-80-5', '8013-13-6', '12795-06-1']
    """
    result = self.get_by_cid(cid)
    return result['cas_numbers'] if result else None
cid_to_inchikey(cid)

Convert PubChem CID to InChIKey.

Parameters:

Name Type Description Default
cid int

PubChem Compound ID.

required

Returns:

Type Description
Optional[str]

The standard InChIKey, or None if not found.

Examples:

>>> db = PubChemID()
>>> db.cid_to_inchikey(2244)
'BSYNRYMUTXBXSQ-UHFFFAOYSA-N'
Source code in src/provesid/pubchem_id.py
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
def cid_to_inchikey(self, cid: int) -> Optional[str]:
    """
    Convert PubChem CID to InChIKey.

    Args:
        cid: PubChem Compound ID.

    Returns:
        The standard InChIKey, or None if not found.

    Examples:
        >>> db = PubChemID()
        >>> db.cid_to_inchikey(2244)
        'BSYNRYMUTXBXSQ-UHFFFAOYSA-N'
    """
    result = self.get_by_cid(cid)
    return result['inchikey'] if result else None
cid_to_inchi(cid)

Convert PubChem CID to InChI.

Parameters:

Name Type Description Default
cid int

PubChem Compound ID.

required

Returns:

Type Description
Optional[str]

The standard InChI, or None if not found.

Examples:

>>> db = PubChemID()
>>> db.cid_to_inchi(702)
'InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3'
Source code in src/provesid/pubchem_id.py
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
def cid_to_inchi(self, cid: int) -> Optional[str]:
    """
    Convert PubChem CID to InChI.

    Args:
        cid: PubChem Compound ID.

    Returns:
        The standard InChI, or None if not found.

    Examples:
        >>> db = PubChemID()
        >>> db.cid_to_inchi(702)
        'InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3'
    """
    result = self.get_by_cid(cid)
    return result['inchi'] if result else None
cid_to_smiles(cid)

Convert PubChem CID to SMILES.

Parameters:

Name Type Description Default
cid int

PubChem Compound ID.

required

Returns:

Type Description
Optional[str]

PubChem's isomeric SMILES, or None if not found.

Examples:

>>> db = PubChemID()
>>> db.cid_to_smiles(2244)
'CC(=O)OC1=CC=CC=C1C(=O)O'
Source code in src/provesid/pubchem_id.py
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
def cid_to_smiles(self, cid: int) -> Optional[str]:
    """
    Convert PubChem CID to SMILES.

    Args:
        cid: PubChem Compound ID.

    Returns:
        PubChem's isomeric SMILES, or None if not found.

    Examples:
        >>> db = PubChemID()
        >>> db.cid_to_smiles(2244)
        'CC(=O)OC1=CC=CC=C1C(=O)O'
    """
    result = self.get_by_cid(cid)
    return result['smiles'] if result else None
smiles_to_cid(smiles)

Convert SMILES string to PubChem CID.

Parameters:

Name Type Description Default
smiles str

SMILES, matched as a string against PubChem's; see get_by_smiles.

required

Returns:

Type Description
Optional[int]

The CID, or None if not found.

Examples:

>>> db = PubChemID()
>>> db.smiles_to_cid("CCO")
702
Source code in src/provesid/pubchem_id.py
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
def smiles_to_cid(self, smiles: str) -> Optional[int]:
    """
    Convert SMILES string to PubChem CID.

    Args:
        smiles: SMILES, matched as a string against PubChem's; see
            [`get_by_smiles`][provesid.pubchem_id.PubChemID.get_by_smiles].

    Returns:
        The CID, or None if not found.

    Examples:
        >>> db = PubChemID()
        >>> db.smiles_to_cid("CCO")
        702
    """
    result = self.get_by_smiles(smiles)
    return result['cid'] if result else None
batch_cas_to_cid(cas_list)

Convert multiple CAS numbers to CIDs.

Parameters:

Name Type Description Default
cas_list list

List of CAS numbers

required

Returns:

Type Description
dict

Mapping of CAS -> CID (None if not found), in input order.

Examples:

>>> db = PubChemID()
>>> results = db.batch_cas_to_cid(["50-78-2", "50-00-0"])
>>> print(results)
{'50-78-2': 2244, '50-00-0': 712}
Source code in src/provesid/pubchem_id.py
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
def batch_cas_to_cid(self, cas_list: List[str]) -> Dict[str, Optional[int]]:
    """
    Convert multiple CAS numbers to CIDs.

    Args:
        cas_list (list): List of CAS numbers

    Returns:
        (dict): Mapping of CAS -> CID (None if not found), in input order.

    Examples:
        >>> db = PubChemID()
        >>> results = db.batch_cas_to_cid(["50-78-2", "50-00-0"])
        >>> print(results)
        {'50-78-2': 2244, '50-00-0': 712}
    """
    results = {}
    for cas in cas_list:
        results[cas] = self.cas_to_cid(cas)
    return results
batch_cas_to_inchikey(cas_list)

Convert multiple CAS numbers to InChIKeys.

Parameters:

Name Type Description Default
cas_list list

List of CAS numbers

required

Returns:

Type Description
dict

Mapping of CAS -> InChIKey (None if not found)

Examples:

>>> db = PubChemID()
>>> db.batch_cas_to_inchikey(["50-78-2", "0-00-0"])
{'50-78-2': 'BSYNRYMUTXBXSQ-UHFFFAOYSA-N', '0-00-0': None}
Source code in src/provesid/pubchem_id.py
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
def batch_cas_to_inchikey(self, cas_list: List[str]) -> Dict[str, Optional[str]]:
    """
    Convert multiple CAS numbers to InChIKeys.

    Args:
        cas_list (list): List of CAS numbers

    Returns:
        (dict): Mapping of CAS -> InChIKey (None if not found)

    Examples:
        >>> db = PubChemID()
        >>> db.batch_cas_to_inchikey(["50-78-2", "0-00-0"])
        {'50-78-2': 'BSYNRYMUTXBXSQ-UHFFFAOYSA-N', '0-00-0': None}
    """
    results = {}
    for cas in cas_list:
        results[cas] = self.cas_to_inchikey(cas)
    return results
batch_cid_to_cas(cid_list)

Convert multiple CIDs to CAS numbers.

Parameters:

Name Type Description Default
cid_list list

List of PubChem CIDs

required

Returns:

Type Description
dict

Mapping of CID -> list of CAS numbers (None if not found)

Examples:

>>> db = PubChemID()
>>> db.batch_cid_to_cas([2244, 702])
{2244: ['50-78-2'], 702: ['64-17-5']}
Source code in src/provesid/pubchem_id.py
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
def batch_cid_to_cas(self, cid_list: List[int]) -> Dict[int, Optional[List[str]]]:
    """
    Convert multiple CIDs to CAS numbers.

    Args:
        cid_list (list): List of PubChem CIDs

    Returns:
        (dict): Mapping of CID -> list of CAS numbers (None if not found)

    Examples:
        >>> db = PubChemID()
        >>> db.batch_cid_to_cas([2244, 702])
        {2244: ['50-78-2'], 702: ['64-17-5']}
    """
    results = {}
    for cid in cid_list:
        results[cid] = self.cid_to_cas(cid)
    return results
batch_smiles_to_cid(smiles_list)

Convert multiple SMILES strings to CIDs.

Parameters:

Name Type Description Default
smiles_list list

List of SMILES strings

required

Returns:

Type Description
dict

Mapping of SMILES -> CID (None if not found)

Examples:

>>> db = PubChemID()
>>> results = db.batch_smiles_to_cid(["CC(=O)OC1=CC=CC=C1C(=O)O", "C"])
>>> print(results)
{'CC(=O)OC1=CC=CC=C1C(=O)O': 2244, 'C': 297}
Source code in src/provesid/pubchem_id.py
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
def batch_smiles_to_cid(self, smiles_list: List[str]) -> Dict[str, Optional[int]]:
    """
    Convert multiple SMILES strings to CIDs.

    Args:
        smiles_list (list): List of SMILES strings

    Returns:
        (dict): Mapping of SMILES -> CID (None if not found)

    Examples:
        >>> db = PubChemID()
        >>> results = db.batch_smiles_to_cid(["CC(=O)OC1=CC=CC=C1C(=O)O", "C"])
        >>> print(results)
        {'CC(=O)OC1=CC=CC=C1C(=O)O': 2244, 'C': 297}
    """
    results = {}
    for smiles in smiles_list:
        results[smiles] = self.smiles_to_cid(smiles)
    return results
get_by_cas_batch(cas_list)

Get complete compound information for multiple CAS numbers as a DataFrame.

One row per CAS number found, carrying every column of the compounds table. Which columns those are depends on how the database was made: one built from PubChem's FTP site has monoisotopicmass, a Zenodo copy has the eight descriptor columns instead (xlogp, polararea and the like).

Parameters:

Name Type Description Default
cas_list list

List of CAS Registry Numbers

required

Returns:

Type Description
DataFrame

cid, cas and then the compounds columns --- cmpdname, mf, inchi, smiles, inchikey, iupacname, mw, exactmass, cidcdate and whichever others the database has. Empty, with those columns, when nothing is found.

Examples:

>>> db = PubChemID()
>>> cas_list = ["50-78-2", "50-00-0", "64-17-5"]
>>> df = db.get_by_cas_batch(cas_list)
>>> print(df[['cas', 'cmpdname', 'mf', 'mw']])
       cas      cmpdname      mf       mw
0  50-78-2       Aspirin  C9H8O4  180.160
1  50-00-0  Formaldehyde    CH2O   30.026
2  64-17-5       Ethanol   C2H6O   46.070
Source code in src/provesid/pubchem_id.py
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
def get_by_cas_batch(self, cas_list: List[str]) -> 'pd.DataFrame':
    """
    Get complete compound information for multiple CAS numbers as a DataFrame.

    One row per CAS number found, carrying every column of the
    ``compounds`` table. Which columns those are depends on how the
    database was made: one built from PubChem's FTP site has
    ``monoisotopicmass``, a Zenodo copy has the eight descriptor columns
    instead (``xlogp``, ``polararea`` and the like).

    Args:
        cas_list (list): List of CAS Registry Numbers

    Returns:
        (pandas.DataFrame): ``cid``, ``cas`` and then the ``compounds``
        columns --- ``cmpdname``, ``mf``, ``inchi``, ``smiles``,
        ``inchikey``, ``iupacname``, ``mw``, ``exactmass``, ``cidcdate``
        and whichever others the database has. Empty, with those columns,
        when nothing is found.

    Examples:
        >>> db = PubChemID()
        >>> cas_list = ["50-78-2", "50-00-0", "64-17-5"]
        >>> df = db.get_by_cas_batch(cas_list)
        >>> print(df[['cas', 'cmpdname', 'mf', 'mw']])
               cas      cmpdname      mf       mw
        0  50-78-2       Aspirin  C9H8O4  180.160
        1  50-00-0  Formaldehyde    CH2O   30.026
        2  64-17-5       Ethanol   C2H6O   46.070
    """
    rows = []
    for cas in cas_list:
        result = self.get_by_cas(cas)
        if result:
            rows.append({'cas': cas, **self._compound_columns(result)})
    return pd.DataFrame(rows, columns=['cid', 'cas'] + self._compound_column_names()[1:])
get_id_table_from_cas(cas)

Get identifier table for a CAS number (similar to ZeroPM format).

Parameters:

Name Type Description Default
cas str

CAS Registry Number

required

Returns:

Type Description
DataFrame

Table with columns [cid, cas, inchi, inchikey, smiles, cmpdname, mf, mw] or None if not found

Examples:

>>> db = PubChemID()
>>> df = db.get_id_table_from_cas("50-78-2")
>>> df[['cid', 'cas', 'cmpdname', 'mf', 'mw']].to_dict('records')
[{'cid': 2244, 'cas': '50-78-2', 'cmpdname': 'Aspirin', 'mf': 'C9H8O4', 'mw': 180.16}]
>>> db.get_id_table_from_cas("0-00-0") is None
True
Source code in src/provesid/pubchem_id.py
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
def get_id_table_from_cas(self, cas: str) -> Optional['pd.DataFrame']:
    """
    Get identifier table for a CAS number (similar to ZeroPM format).

    Args:
        cas (str): CAS Registry Number

    Returns:
        (pandas.DataFrame): Table with columns [cid, cas, inchi, inchikey, smiles,
                         cmpdname, mf, mw] or None if not found

    Examples:
        >>> db = PubChemID()
        >>> df = db.get_id_table_from_cas("50-78-2")
        >>> df[['cid', 'cas', 'cmpdname', 'mf', 'mw']].to_dict('records')
        [{'cid': 2244, 'cas': '50-78-2', 'cmpdname': 'Aspirin', 'mf': 'C9H8O4', 'mw': 180.16}]
        >>> db.get_id_table_from_cas("0-00-0") is None
        True
    """
    import pandas as pd

    result = self.get_by_cas(cas)
    if not result:
        return None

    # Create DataFrame with main identifiers and properties
    df = pd.DataFrame([{
        'cid': result['cid'],
        'cas': cas,
        'inchi': result.get('inchi', ''),
        'inchikey': result.get('inchikey', ''),
        'smiles': result.get('smiles', ''),
        'cmpdname': result.get('cmpdname', ''),
        'mf': result.get('mf', ''),
        'mw': result.get('mw', None)
    }])

    return df
batch_get_id_table_from_cas(cas_list)

Get identifier tables for multiple CAS numbers.

Parameters:

Name Type Description Default
cas_list list

List of CAS Registry Numbers

required

Returns:

Type Description
DataFrame

One get_id_table_from_cas row per CAS number found; CAS numbers not found are left out. Empty, with the same columns, when none is found.

Examples:

>>> db = PubChemID()
>>> df = db.batch_get_id_table_from_cas(["50-78-2", "0-00-0", "64-17-5"])
>>> print(df[['cid', 'cas', 'cmpdname', 'mf']])
    cid      cas cmpdname      mf
0  2244  50-78-2  Aspirin  C9H8O4
1   702  64-17-5  Ethanol   C2H6O
Source code in src/provesid/pubchem_id.py
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
def batch_get_id_table_from_cas(self, cas_list: List[str]) -> 'pd.DataFrame':
    """
    Get identifier tables for multiple CAS numbers.

    Args:
        cas_list (list): List of CAS Registry Numbers

    Returns:
        (pandas.DataFrame): One
        [`get_id_table_from_cas`][provesid.pubchem_id.PubChemID.get_id_table_from_cas]
        row per CAS number found; CAS numbers not found are left out.
        Empty, with the same columns, when none is found.

    Examples:
        >>> db = PubChemID()
        >>> df = db.batch_get_id_table_from_cas(["50-78-2", "0-00-0", "64-17-5"])
        >>> print(df[['cid', 'cas', 'cmpdname', 'mf']])
            cid      cas cmpdname      mf
        0  2244  50-78-2  Aspirin  C9H8O4
        1   702  64-17-5  Ethanol   C2H6O
    """
    import pandas as pd

    tables = []
    for cas in cas_list:
        df = self.get_id_table_from_cas(cas)
        if df is not None:
            tables.append(df)

    if not tables:
        # Return empty DataFrame with correct columns
        return pd.DataFrame(columns=['cid', 'cas', 'inchi', 'inchikey',
                                    'smiles', 'cmpdname', 'mf', 'mw'])

    return pd.concat(tables, ignore_index=True)
get_by_smiles_batch(smiles_list)

Get complete compound information for multiple SMILES strings as a DataFrame.

One row per SMILES found, carrying the compound's first CAS number and every column of the compounds table; see get_by_cas_batch for how those columns depend on where the database came from.

Parameters:

Name Type Description Default
smiles_list list

List of SMILES strings

required

Returns:

Type Description
DataFrame

cid, cas and then the compounds columns. Empty, with those columns, when nothing is found.

Examples:

>>> db = PubChemID()
>>> smiles_list = ["CC(=O)OC1=CC=CC=C1C(=O)O", "C", "CCO"]
>>> df = db.get_by_smiles_batch(smiles_list)
>>> print(df[['smiles', 'cmpdname', 'mf', 'mw']])
                     smiles cmpdname      mf       mw
0  CC(=O)OC1=CC=CC=C1C(=O)O  Aspirin  C9H8O4  180.160
1                         C  Methane     CH4   16.043
2                       CCO  Ethanol   C2H6O   46.070
Source code in src/provesid/pubchem_id.py
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
def get_by_smiles_batch(self, smiles_list: List[str]) -> 'pd.DataFrame':
    """
    Get complete compound information for multiple SMILES strings as a DataFrame.

    One row per SMILES found, carrying the compound's first CAS number and
    every column of the ``compounds`` table; see
    [`get_by_cas_batch`][provesid.pubchem_id.PubChemID.get_by_cas_batch]
    for how those columns depend on where the database came from.

    Args:
        smiles_list (list): List of SMILES strings

    Returns:
        (pandas.DataFrame): ``cid``, ``cas`` and then the ``compounds``
        columns. Empty, with those columns, when nothing is found.

    Examples:
        >>> db = PubChemID()
        >>> smiles_list = ["CC(=O)OC1=CC=CC=C1C(=O)O", "C", "CCO"]
        >>> df = db.get_by_smiles_batch(smiles_list)
        >>> print(df[['smiles', 'cmpdname', 'mf', 'mw']])
                             smiles cmpdname      mf       mw
        0  CC(=O)OC1=CC=CC=C1C(=O)O  Aspirin  C9H8O4  180.160
        1                         C  Methane     CH4   16.043
        2                       CCO  Ethanol   C2H6O   46.070
    """
    rows = []
    for smiles in smiles_list:
        result = self.get_by_smiles(smiles)
        if result:
            cas_numbers = result.get('cas_numbers') or [None]
            rows.append({'cas': cas_numbers[0], **self._compound_columns(result)})
    return pd.DataFrame(rows, columns=['cid', 'cas'] + self._compound_column_names()[1:])
smiles_to_cas(smiles)

Convert SMILES string to CAS number(s).

Unlike smiles_to_cid, this compares structures: the SMILES is converted to a standard InChI with RDKit and looked up by that, so any valid SMILES for the compound finds it.

Parameters:

Name Type Description Default
smiles str

SMILES string

required

Returns:

Type Description
list

List of CAS numbers, or None if not found, if RDKit cannot parse the SMILES, or if RDKit is not installed.

Examples:

>>> db = PubChemID()
>>> db.smiles_to_cas("CC(=O)OC1=CC=CC=C1C(=O)O")  # Aspirin
['50-78-2']
>>> db.smiles_to_cas("OCC"), db.smiles_to_cid("OCC")
(['64-17-5'], None)
Source code in src/provesid/pubchem_id.py
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
def smiles_to_cas(self, smiles: str) -> Optional[List[str]]:
    """
    Convert SMILES string to CAS number(s).

    Unlike [`smiles_to_cid`][provesid.pubchem_id.PubChemID.smiles_to_cid],
    this compares structures: the SMILES is converted to a standard InChI
    with RDKit and looked up by that, so any valid SMILES for the compound
    finds it.

    Args:
        smiles (str): SMILES string

    Returns:
        (list): List of CAS numbers, or None if not found, if RDKit cannot
        parse the SMILES, or if RDKit is not installed.

    Examples:
        >>> db = PubChemID()
        >>> db.smiles_to_cas("CC(=O)OC1=CC=CC=C1C(=O)O")  # Aspirin
        ['50-78-2']
        >>> db.smiles_to_cas("OCC"), db.smiles_to_cid("OCC")
        (['64-17-5'], None)
    """
    # First convert SMILES to InChI using RDKit
    try:
        from rdkit import Chem
        mol = Chem.MolFromSmiles(smiles)
        if mol is None:
            return None
        inchi = Chem.MolToInchi(mol)
    except Exception:
        return None

    # Then look up by InChI
    return self.inchi_to_cas(inchi)
name_to_cas(name, exact=True)

Convert chemical name to CAS number(s).

Parameters:

Name Type Description Default
name str

Chemical name or synonym

required
exact bool

If True, exact match only. If False, returns first match from search.

True

Returns:

Type Description
list

The first matching compound's CAS numbers, or None if no compound matches. See search_by_name for how names match.

Examples:

>>> db = PubChemID()
>>> db.name_to_cas("aspirin")
['50-78-2']
>>> db.name_to_cas("no such compound") is None
True
Note

For exact=False, only the first match from the search is returned. Use search_by_name() for more control over multiple matches.

Source code in src/provesid/pubchem_id.py
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
def name_to_cas(self, name: str, exact: bool = True) -> Optional[List[str]]:
    """
    Convert chemical name to CAS number(s).

    Args:
        name (str): Chemical name or synonym
        exact (bool): If True, exact match only. If False, returns first match from search.

    Returns:
        (list): The first matching compound's CAS numbers, or None if no
        compound matches. See
        [`search_by_name`][provesid.pubchem_id.PubChemID.search_by_name]
        for how names match.

    Examples:
        >>> db = PubChemID()
        >>> db.name_to_cas("aspirin")
        ['50-78-2']
        >>> db.name_to_cas("no such compound") is None
        True

    Note:
        For exact=False, only the first match from the search is returned.
        Use search_by_name() for more control over multiple matches.
    """
    results = self.search_by_name(name, exact=exact, limit=1)
    if not results:
        return None
    return results[0].get('cas_numbers')
formula_to_cas(formula, limit=100)

Convert molecular formula to CAS numbers.

Note: Molecular formulas are not unique - many isomers can share the same formula. This method returns CAS numbers for all compounds matching the formula.

Parameters:

Name Type Description Default
formula str

Molecular formula (e.g., "C9H8O4", "CH2O")

required
limit int

Maximum number of compounds to retrieve

100

Returns:

Type Description
list

The distinct CAS numbers of the first limit compounds with this formula, sorted as strings, or None if none is found

Examples:

>>> db = PubChemID()
>>> cas_list = db.formula_to_cas("C9H8O4")
>>> "50-78-2" in cas_list, cas_list == sorted(cas_list)
(True, True)
Warning

Can return many results for common formulas. Use limit parameter to control.

Source code in src/provesid/pubchem_id.py
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
def formula_to_cas(self, formula: str, limit: int = 100) -> Optional[List[str]]:
    """
    Convert molecular formula to CAS numbers.

    Note: Molecular formulas are not unique - many isomers can share the same formula.
    This method returns CAS numbers for all compounds matching the formula.

    Args:
        formula (str): Molecular formula (e.g., "C9H8O4", "CH2O")
        limit (int): Maximum number of compounds to retrieve

    Returns:
        (list): The distinct CAS numbers of the first ``limit`` compounds
        with this formula, sorted as strings, or None if none is found

    Examples:
        >>> db = PubChemID()
        >>> cas_list = db.formula_to_cas("C9H8O4")
        >>> "50-78-2" in cas_list, cas_list == sorted(cas_list)
        (True, True)

    Warning:
        Can return many results for common formulas. Use limit parameter to control.
    """
    results = self.search_by_formula(formula, limit=limit)
    if not results:
        return None

    # Collect all unique CAS numbers from all matching compounds
    all_cas = []
    for compound in results:
        cas_numbers = compound.get('cas_numbers', [])
        if cas_numbers:
            all_cas.extend(cas_numbers)

    # Remove duplicates and sort
    unique_cas = sorted(set(all_cas))
    return unique_cas if unique_cas else None
batch_smiles_to_cas(smiles_list)

Convert multiple SMILES strings to CAS numbers.

Parameters:

Name Type Description Default
smiles_list list

List of SMILES strings

required

Returns:

Type Description
dict

Mapping of SMILES -> list of CAS numbers (None if not found)

Examples:

>>> db = PubChemID()
>>> db.batch_smiles_to_cas(["OCC", "not a smiles"])
{'OCC': ['64-17-5'], 'not a smiles': None}
Source code in src/provesid/pubchem_id.py
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
def batch_smiles_to_cas(self, smiles_list: List[str]) -> Dict[str, Optional[List[str]]]:
    """
    Convert multiple SMILES strings to CAS numbers.

    Args:
        smiles_list (list): List of SMILES strings

    Returns:
        (dict): Mapping of SMILES -> list of CAS numbers (None if not found)

    Examples:
        >>> db = PubChemID()
        >>> db.batch_smiles_to_cas(["OCC", "not a smiles"])
        {'OCC': ['64-17-5'], 'not a smiles': None}
    """
    return {smiles: self.smiles_to_cas(smiles) for smiles in smiles_list}
batch_name_to_cas(name_list, exact=True)

Convert multiple chemical names to CAS numbers.

Parameters:

Name Type Description Default
name_list list

List of chemical names

required
exact bool

If True, exact match only

True

Returns:

Type Description
dict

Mapping of name -> list of CAS numbers (None if not found)

Examples:

>>> db = PubChemID()
>>> db.batch_name_to_cas(["aspirin", "ethanol", "xyzzy"])
{'aspirin': ['50-78-2'], 'ethanol': ['64-17-5'], 'xyzzy': None}
Source code in src/provesid/pubchem_id.py
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
def batch_name_to_cas(self, name_list: List[str], exact: bool = True) -> Dict[str, Optional[List[str]]]:
    """
    Convert multiple chemical names to CAS numbers.

    Args:
        name_list (list): List of chemical names
        exact (bool): If True, exact match only

    Returns:
        (dict): Mapping of name -> list of CAS numbers (None if not found)

    Examples:
        >>> db = PubChemID()
        >>> db.batch_name_to_cas(["aspirin", "ethanol", "xyzzy"])
        {'aspirin': ['50-78-2'], 'ethanol': ['64-17-5'], 'xyzzy': None}
    """
    return {name: self.name_to_cas(name, exact=exact) for name in name_list}
batch_formula_to_cas(formula_list, limit=100)

Convert multiple molecular formulas to CAS numbers.

Parameters:

Name Type Description Default
formula_list list

List of molecular formulas

required
limit int

Maximum number of compounds per formula

100

Returns:

Type Description
dict

Mapping of formula -> list of CAS numbers (None if not found)

Examples:

>>> db = PubChemID()
>>> results = db.batch_formula_to_cas(["H2O", "CH4", "XeF9"])
>>> "7732-18-5" in results["H2O"], "74-82-8" in results["CH4"], results["XeF9"]
(True, True, None)
Source code in src/provesid/pubchem_id.py
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
def batch_formula_to_cas(self, formula_list: List[str], limit: int = 100) -> Dict[str, Optional[List[str]]]:
    """
    Convert multiple molecular formulas to CAS numbers.

    Args:
        formula_list (list): List of molecular formulas
        limit (int): Maximum number of compounds per formula

    Returns:
        (dict): Mapping of formula -> list of CAS numbers (None if not found)

    Examples:
        >>> db = PubChemID()
        >>> results = db.batch_formula_to_cas(["H2O", "CH4", "XeF9"])
        >>> "7732-18-5" in results["H2O"], "74-82-8" in results["CH4"], results["XeF9"]
        (True, True, None)
    """
    return {formula: self.formula_to_cas(formula, limit=limit) for formula in formula_list}
properties(cid, properties=None, use_online_fallback=True)

Look up computed properties for one compound, offline first.

The local database answers from disk in microseconds; the online API is consulted only when the local database cannot serve the request, either because it holds no row for this CID or because a requested property is not one of the columns it carries (see offline_properties).

Parameters:

Name Type Description Default
cid Union[int, str]

PubChem Compound ID.

required
properties Optional[List[str]]

Property names to retrieve, e.g. ['MolecularWeight', 'XLogP']. Defaults to every property the local database can answer, offline_properties.

None
use_online_fallback bool

When True (default), fall back to PUG-REST for anything the local database cannot answer. When False, the lookup is strictly offline, and a request the local database cannot answer in full --- an unknown CID, or any property outside offline_properties --- returns None.

True

Returns:

Type Description
Optional[Dict[str, Any]]

A dict carrying CID, a Source of 'offline' or 'online', and one key per property that has a value. A property the compound has no value for is omitted rather than set to None, which is how PubChem itself reports it — so 'XLogP' not in result means PubChem computes no logP for this compound, not that the lookup fell short. Returns None when neither source knows the CID, or when use_online_fallback is False and the request needs the network.

Raises:

Type Description
ValueError

If cid is not an integer, or properties is an empty list.

PubChemError

If the online fallback was needed and its request could not be completed. An incomplete answer is never passed off as a complete one.

Examples:

>>> db = PubChemID()
>>> db.properties(2244, ['MolecularFormula', 'MolecularWeight'])
{'CID': 2244, 'Source': 'offline', 'MolecularFormula': 'C9H8O4', 'MolecularWeight': 180.16}
>>> # XLogP is PubChem's model output, never served from disk
>>> db.properties(2244, ['XLogP'])['Source']
'online'
>>> db.properties(2244, ['XLogP'], use_online_fallback=False) is None
True
Source code in src/provesid/pubchem_id.py
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
def properties(self, cid: Union[int, str],
               properties: Optional[List[str]] = None,
               use_online_fallback: bool = True) -> Optional[Dict[str, Any]]:
    """
    Look up computed properties for one compound, offline first.

    The local database answers from disk in microseconds; the online API is
    consulted only when the local database cannot serve the request, either
    because it holds no row for this CID or because a requested property is
    not one of the columns it carries (see
    [`offline_properties`][provesid.pubchem_id.PubChemID]).

    Args:
        cid: PubChem Compound ID.
        properties: Property names to retrieve, e.g.
            ``['MolecularWeight', 'XLogP']``. Defaults to every property the
            local database can answer,
            [`offline_properties`][provesid.pubchem_id.PubChemID].
        use_online_fallback: When True (default), fall back to PUG-REST for
            anything the local database cannot answer. When False, the
            lookup is strictly offline, and a request the local database
            cannot answer in full --- an unknown CID, or any property
            outside [`offline_properties`][provesid.pubchem_id.PubChemID]
            --- returns None.

    Returns:
        A dict carrying ``CID``, a ``Source`` of ``'offline'`` or
        ``'online'``, and one key per property that has a value. A property
        the compound has no value for is omitted rather than set to None,
        which is how PubChem itself reports it — so ``'XLogP' not in
        result`` means PubChem computes no logP for this compound, not that
        the lookup fell short. Returns None when neither source knows the
        CID, or when ``use_online_fallback`` is False and the request needs
        the network.

    Raises:
        ValueError: If ``cid`` is not an integer, or ``properties`` is an
            empty list.
        PubChemError: If the online fallback was needed and its request
            could not be completed. An incomplete answer is never passed off
            as a complete one.

    Examples:
        >>> db = PubChemID()
        >>> db.properties(2244, ['MolecularFormula', 'MolecularWeight'])
        {'CID': 2244, 'Source': 'offline', 'MolecularFormula': 'C9H8O4', 'MolecularWeight': 180.16}
        >>> # XLogP is PubChem's model output, never served from disk
        >>> db.properties(2244, ['XLogP'])['Source']            # doctest: +SKIP
        'online'
        >>> db.properties(2244, ['XLogP'], use_online_fallback=False) is None
        True
    """
    rows = self.properties_for_cids([cid], properties,
                                    use_online_fallback=use_online_fallback)
    return rows[0] if rows else None
properties_for_cids(cids, properties=None, use_online_fallback=True, chunk_size=PROPERTY_CHUNK_SIZE)

Look up computed properties for many compounds, offline first.

Everything the local database can answer is read in a handful of SQL statements; only the remainder is requested from PubChem, in bulk, a few hundred compounds per request. A list of ten thousand CIDs that the local database covers therefore costs no network traffic at all.

Parameters:

Name Type Description Default
cids List[Union[int, str]]

PubChem Compound IDs. Duplicates are collapsed and the order of first appearance is preserved.

required
properties Optional[List[str]]

Property names to retrieve. Defaults to offline_properties.

None
use_online_fallback bool

When True (default), CIDs the local database does not cover are requested from PUG-REST.

True
chunk_size int

How many CIDs to put in one online request.

PROPERTY_CHUNK_SIZE

Returns:

Type Description
List[Dict[str, Any]]

One dict per CID that could be answered, in the order requested, each carrying CID, a Source of 'offline' or 'online', and one key per property that has a value. CIDs neither source knows are omitted; use properties_table to get a row for every CID asked about.

Raises:

Type Description
ValueError

If a CID is not an integer, properties is an empty list, or chunk_size is not positive.

PubChemError

If an online request could not be completed.

Note

If any requested property lies outside offline_properties, the whole request goes online: the missing property would need a request per compound anyway, so splitting the property list between the two sources would cost the same traffic and return rows assembled from two different PubChem snapshots.

Examples:

>>> db = PubChemID()
>>> rows = db.properties_for_cids([2244, 702], ['MolecularFormula'])
>>> for row in rows:
...     print(row['CID'], row['MolecularFormula'], row['Source'])
2244 C9H8O4 offline
702 C2H6O offline
Source code in src/provesid/pubchem_id.py
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
def properties_for_cids(self, cids: List[Union[int, str]],
                        properties: Optional[List[str]] = None,
                        use_online_fallback: bool = True,
                        chunk_size: int = PROPERTY_CHUNK_SIZE) -> List[Dict[str, Any]]:
    """
    Look up computed properties for many compounds, offline first.

    Everything the local database can answer is read in a handful of SQL
    statements; only the remainder is requested from PubChem, in bulk, a few
    hundred compounds per request. A list of ten thousand CIDs that the
    local database covers therefore costs no network traffic at all.

    Args:
        cids: PubChem Compound IDs. Duplicates are collapsed and the order
            of first appearance is preserved.
        properties: Property names to retrieve. Defaults to
            [`offline_properties`][provesid.pubchem_id.PubChemID].
        use_online_fallback: When True (default), CIDs the local database
            does not cover are requested from PUG-REST.
        chunk_size: How many CIDs to put in one online request.

    Returns:
        One dict per CID that could be answered, in the order requested,
        each carrying ``CID``, a ``Source`` of ``'offline'`` or
        ``'online'``, and one key per property that has a value. CIDs
        neither source knows are omitted; use
        [`properties_table`][provesid.pubchem_id.PubChemID.properties_table]
        to get a row for every CID asked about.

    Raises:
        ValueError: If a CID is not an integer, ``properties`` is an empty
            list, or ``chunk_size`` is not positive.
        PubChemError: If an online request could not be completed.

    Note:
        If *any* requested property lies outside
        [`offline_properties`][provesid.pubchem_id.PubChemID], the whole
        request goes online: the missing property would need a request per
        compound anyway, so splitting the property list between the two
        sources would cost the same traffic and return rows assembled from
        two different PubChem snapshots.

    Examples:
        >>> db = PubChemID()
        >>> rows = db.properties_for_cids([2244, 702], ['MolecularFormula'])
        >>> for row in rows:
        ...     print(row['CID'], row['MolecularFormula'], row['Source'])
        2244 C9H8O4 offline
        702 C2H6O offline
    """
    if properties is not None and not properties:
        raise ValueError("properties must name at least one property, or be None")
    if chunk_size <= 0:
        raise ValueError(f"chunk_size must be positive, got {chunk_size}")

    requested_properties = list(properties) if properties else list(self.offline_properties)
    wanted_cids = [self._coerce_cid(cid) for cid in cids]
    wanted_cids = list(dict.fromkeys(wanted_cids))
    if not wanted_cids:
        return []

    online_only = [name for name in requested_properties
                   if name not in self.offline_properties]

    found: Dict[int, Dict[str, Any]] = {}
    if online_only:
        self.logger.debug(
            "Going straight online for %d CIDs: %s not in the local database",
            len(wanted_cids), ', '.join(online_only))
        missing = wanted_cids
    else:
        found = self._offline_properties(wanted_cids, requested_properties)
        missing = [cid for cid in wanted_cids if cid not in found]
        self.logger.debug("Served %d/%d CIDs offline", len(found), len(wanted_cids))

    if missing and use_online_fallback:
        self.logger.debug("Falling back online for %d CIDs", len(missing))
        found.update(self._online_properties(missing, requested_properties, chunk_size))

    return [found[cid] for cid in wanted_cids if cid in found]
properties_table(cids, properties=None, use_online_fallback=True, chunk_size=PROPERTY_CHUNK_SIZE)

Offline-first property lookup for many compounds, as a DataFrame.

Same lookup as properties_for_cids, reshaped so that every CID asked about has a row whether or not it could be answered. That makes the frame safe to concatenate or join against the caller's own table.

Parameters:

Name Type Description Default
cids List[Union[int, str]]

PubChem Compound IDs. Duplicates are collapsed.

required
properties Optional[List[str]]

Property names to retrieve. Defaults to offline_properties.

None
use_online_fallback bool

When True (default), consult PUG-REST for CIDs the local database does not cover.

True
chunk_size int

How many CIDs to put in one online request.

PROPERTY_CHUNK_SIZE

Returns:

Type Description
DataFrame

A DataFrame with one row per distinct CID in the order requested. Columns are CID, Source and the requested properties. Source reads 'offline', 'online', or 'missing' for a CID neither source knows; a property with no value is NaN/None.

Raises:

Type Description
ValueError

If a CID is not an integer, properties is an empty list, or chunk_size is not positive.

PubChemError

If an online request could not be completed.

Examples:

>>> db = PubChemID()
>>> table = db.properties_table([2244, 702], ['MolecularWeight'])
>>> table[['CID', 'MolecularWeight', 'Source']].to_dict('records')
[{'CID': 2244, 'MolecularWeight': 180.16, 'Source': 'offline'},
 {'CID': 702, 'MolecularWeight': 46.07, 'Source': 'offline'}]
Source code in src/provesid/pubchem_id.py
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
def properties_table(self, cids: List[Union[int, str]],
                     properties: Optional[List[str]] = None,
                     use_online_fallback: bool = True,
                     chunk_size: int = PROPERTY_CHUNK_SIZE) -> 'pd.DataFrame':
    """
    Offline-first property lookup for many compounds, as a DataFrame.

    Same lookup as
    [`properties_for_cids`][provesid.pubchem_id.PubChemID.properties_for_cids],
    reshaped so that every CID asked about has a row whether or not it
    could be answered. That makes the frame safe to concatenate or join
    against the caller's own table.

    Args:
        cids: PubChem Compound IDs. Duplicates are collapsed.
        properties: Property names to retrieve. Defaults to
            [`offline_properties`][provesid.pubchem_id.PubChemID].
        use_online_fallback: When True (default), consult PUG-REST for CIDs
            the local database does not cover.
        chunk_size: How many CIDs to put in one online request.

    Returns:
        A DataFrame with one row per distinct CID in the order requested.
        Columns are ``CID``, ``Source`` and the requested properties.
        ``Source`` reads ``'offline'``, ``'online'``, or ``'missing'`` for a
        CID neither source knows; a property with no value is NaN/None.

    Raises:
        ValueError: If a CID is not an integer, ``properties`` is an empty
            list, or ``chunk_size`` is not positive.
        PubChemError: If an online request could not be completed.

    Examples:
        >>> db = PubChemID()
        >>> table = db.properties_table([2244, 702], ['MolecularWeight'])
        >>> table[['CID', 'MolecularWeight', 'Source']].to_dict('records')
        [{'CID': 2244, 'MolecularWeight': 180.16, 'Source': 'offline'},
         {'CID': 702, 'MolecularWeight': 46.07, 'Source': 'offline'}]
    """
    requested_properties = list(properties) if properties else list(self.offline_properties)
    rows = self.properties_for_cids(cids, requested_properties,
                                    use_online_fallback=use_online_fallback,
                                    chunk_size=chunk_size)
    return self._table(cids, rows, requested_properties)
descriptors(cid, descriptors=None, source='rdkit', use_online_fallback=True)

Computed molecular descriptors for one compound, from RDKit or PubChem.

The local database stores identifiers and structures, not descriptors: XLogP, TPSA and the counts are the output of a model run over the structure, and there is more than one model. This method runs one, and says which:

  • source="rdkit" (default) computes them with RDKit from the compound's stored SMILES --- no network, milliseconds. The record says Source='rdkit'. RDKit and PubChem count some things differently, and the logP is a different model altogether, named MolLogP rather than XLogP; rdkit_descriptors measures how far apart they are. Complexity is not available.
  • source="pubchem" fetches PubChem's own values from PUG-REST, through the same path as properties, labelled Source='online'. This is the only way to PubChem's XLogP and Complexity.

Parameters:

Name Type Description Default
cid Union[int, str]

PubChem Compound ID.

required
descriptors Optional[List[str]]

Names to compute. Defaults to every descriptor the source has: RDKIT_DESCRIPTORS or PUBCHEM_DESCRIPTORS.

None
source str

'rdkit' or 'pubchem'.

'rdkit'
use_online_fallback bool

For source="rdkit", whether a compound the local database does not hold may have its SMILES fetched from PubChem to compute from. When False, such a compound returns None. source="pubchem" is online by definition and does not accept False.

True

Returns:

Type Description
Optional[Dict[str, Any]]

A dict carrying CID, Source and one key per descriptor that has a value, or None when the compound is unknown. A compound whose SMILES RDKit cannot parse --- a handful in a million --- or that has no structure comes back with CID and Source only.

Raises:

Type Description
ValueError

If cid is not an integer, source is unknown, a name is not one source provides (asking RDKit for XLogP says to ask for MolLogP), or source="pubchem" is combined with use_online_fallback=False.

PubChemError

If an online request could not be completed.

Examples:

>>> db = PubChemID()
>>> db.descriptors(2244, ['MolLogP', 'TPSA'])
{'CID': 2244, 'Source': 'rdkit', 'MolLogP': 1.3101, 'TPSA': 63.6}
>>> db.descriptors(2244, ['XLogP'], source='pubchem')
{'CID': 2244, 'Source': 'online', 'XLogP': 1.2}
Source code in src/provesid/pubchem_id.py
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
def descriptors(self, cid: Union[int, str],
                descriptors: Optional[List[str]] = None,
                source: str = 'rdkit',
                use_online_fallback: bool = True) -> Optional[Dict[str, Any]]:
    """
    Computed molecular descriptors for one compound, from RDKit or PubChem.

    The local database stores identifiers and structures, not descriptors:
    XLogP, TPSA and the counts are the output of a model run over the
    structure, and there is more than one model. This method runs one,
    and says which:

    * ``source="rdkit"`` (default) computes them with RDKit from the
      compound's stored SMILES --- no network, milliseconds. The record
      says ``Source='rdkit'``. RDKit and PubChem count some things
      differently, and the logP is a different model altogether, named
      ``MolLogP`` rather than ``XLogP``;
      [`rdkit_descriptors`][provesid.pubchem_id.rdkit_descriptors] measures
      how far apart they are. ``Complexity`` is not available.
    * ``source="pubchem"`` fetches PubChem's own values from PUG-REST,
      through the same path as
      [`properties`][provesid.pubchem_id.PubChemID.properties], labelled
      ``Source='online'``. This is the only way to PubChem's ``XLogP`` and
      ``Complexity``.

    Args:
        cid: PubChem Compound ID.
        descriptors: Names to compute. Defaults to every descriptor the
            source has:
            [`RDKIT_DESCRIPTORS`][provesid.pubchem_id.RDKIT_DESCRIPTORS] or
            [`PUBCHEM_DESCRIPTORS`][provesid.pubchem_id.PUBCHEM_DESCRIPTORS].
        source: ``'rdkit'`` or ``'pubchem'``.
        use_online_fallback: For ``source="rdkit"``, whether a compound the
            local database does not hold may have its SMILES fetched from
            PubChem to compute from. When False, such a compound returns
            None. ``source="pubchem"`` is online by definition and does not
            accept False.

    Returns:
        A dict carrying ``CID``, ``Source`` and one key per descriptor that
        has a value, or None when the compound is unknown. A compound whose
        SMILES RDKit cannot parse --- a handful in a million --- or that
        has no structure comes back with ``CID`` and ``Source`` only.

    Raises:
        ValueError: If ``cid`` is not an integer, ``source`` is unknown,
            a name is not one ``source`` provides (asking RDKit for
            ``XLogP`` says to ask for ``MolLogP``), or ``source="pubchem"``
            is combined with ``use_online_fallback=False``.
        PubChemError: If an online request could not be completed.

    Examples:
        >>> db = PubChemID()
        >>> db.descriptors(2244, ['MolLogP', 'TPSA'])
        {'CID': 2244, 'Source': 'rdkit', 'MolLogP': 1.3101, 'TPSA': 63.6}
        >>> db.descriptors(2244, ['XLogP'], source='pubchem')  # doctest: +SKIP
        {'CID': 2244, 'Source': 'online', 'XLogP': 1.2}
    """
    rows = self.descriptors_for_cids([cid], descriptors, source=source,
                                     use_online_fallback=use_online_fallback)
    return rows[0] if rows else None
descriptors_for_cids(cids, descriptors=None, source='rdkit', use_online_fallback=True, chunk_size=PROPERTY_CHUNK_SIZE)

Computed molecular descriptors for many compounds, from RDKit or PubChem.

The list form of descriptors. With source="rdkit" the SMILES of every compound in the local database are read in a handful of statements, and only those it lacks are fetched from PubChem, in bulk; with source="pubchem" the whole list goes to PUG-REST a few hundred compounds per request.

Parameters:

Name Type Description Default
cids List[Union[int, str]]

PubChem Compound IDs. Duplicates are collapsed and the order of first appearance is preserved.

required
descriptors Optional[List[str]]

Names to compute; defaults to every descriptor the source has.

None
source str

'rdkit' or 'pubchem'.

'rdkit'
use_online_fallback bool

For source="rdkit", whether SMILES missing from the local database may be fetched from PubChem.

True
chunk_size int

How many CIDs to put in one online request.

PROPERTY_CHUNK_SIZE

Returns:

Type Description
List[Dict[str, Any]]

One dict per CID that could be answered, in the order requested, shaped as descriptors describes. CIDs no source knows are omitted; descriptors_table gives a row for every CID.

Raises:

Type Description
ValueError

As for descriptors, or if chunk_size is not positive.

PubChemError

If an online request could not be completed.

Examples:

>>> db = PubChemID()
>>> for row in db.descriptors_for_cids([2244, 702], ['HeavyAtomCount']):
...     print(row)
{'CID': 2244, 'Source': 'rdkit', 'HeavyAtomCount': 13}
{'CID': 702, 'Source': 'rdkit', 'HeavyAtomCount': 3}
Source code in src/provesid/pubchem_id.py
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
def descriptors_for_cids(self, cids: List[Union[int, str]],
                         descriptors: Optional[List[str]] = None,
                         source: str = 'rdkit',
                         use_online_fallback: bool = True,
                         chunk_size: int = PROPERTY_CHUNK_SIZE) -> List[Dict[str, Any]]:
    """
    Computed molecular descriptors for many compounds, from RDKit or PubChem.

    The list form of
    [`descriptors`][provesid.pubchem_id.PubChemID.descriptors]. With
    ``source="rdkit"`` the SMILES of every compound in the local database
    are read in a handful of statements, and only those it lacks are
    fetched from PubChem, in bulk; with ``source="pubchem"`` the whole list
    goes to PUG-REST a few hundred compounds per request.

    Args:
        cids: PubChem Compound IDs. Duplicates are collapsed and the order
            of first appearance is preserved.
        descriptors: Names to compute; defaults to every descriptor the
            source has.
        source: ``'rdkit'`` or ``'pubchem'``.
        use_online_fallback: For ``source="rdkit"``, whether SMILES missing
            from the local database may be fetched from PubChem.
        chunk_size: How many CIDs to put in one online request.

    Returns:
        One dict per CID that could be answered, in the order requested,
        shaped as
        [`descriptors`][provesid.pubchem_id.PubChemID.descriptors]
        describes. CIDs no source knows are omitted;
        [`descriptors_table`][provesid.pubchem_id.PubChemID.descriptors_table]
        gives a row for every CID.

    Raises:
        ValueError: As for
            [`descriptors`][provesid.pubchem_id.PubChemID.descriptors], or
            if ``chunk_size`` is not positive.
        PubChemError: If an online request could not be completed.

    Examples:
        >>> db = PubChemID()
        >>> for row in db.descriptors_for_cids([2244, 702], ['HeavyAtomCount']):
        ...     print(row)
        {'CID': 2244, 'Source': 'rdkit', 'HeavyAtomCount': 13}
        {'CID': 702, 'Source': 'rdkit', 'HeavyAtomCount': 3}
    """
    names = _check_descriptor_names(descriptors, source)

    if source == 'pubchem':
        if not use_online_fallback:
            raise ValueError("source='pubchem' fetches PubChem's values online; "
                             "use_online_fallback=False contradicts it. For "
                             "descriptors without the network use source='rdkit'.")
        return self.properties_for_cids(cids, names, chunk_size=chunk_size)

    structures = self.properties_for_cids(cids, ['SMILES'],
                                          use_online_fallback=use_online_fallback,
                                          chunk_size=chunk_size)
    rows = []
    for structure in structures:
        values = rdkit_descriptors(structure.get('SMILES'), names)
        if values is None:
            self.logger.debug("RDKit could not read the SMILES of CID %d: %r",
                              structure['CID'], structure.get('SMILES'))
        rows.append({'CID': structure['CID'], 'Source': 'rdkit', **(values or {})})
    return rows
descriptors_table(cids, descriptors=None, source='rdkit', use_online_fallback=True, chunk_size=PROPERTY_CHUNK_SIZE)

Computed molecular descriptors for many compounds, as a DataFrame.

Same lookup as descriptors_for_cids, with a row for every CID asked about, so the frame joins safely against the caller's own table. Because the RDKit and PubChem columns share names wherever the quantity is the same, two tables built with each source line up column for column, apart from MolLogP / XLogP and Complexity.

Parameters:

Name Type Description Default
cids List[Union[int, str]]

PubChem Compound IDs. Duplicates are collapsed.

required
descriptors Optional[List[str]]

Names to compute; defaults to every descriptor the source has.

None
source str

'rdkit' or 'pubchem'.

'rdkit'
use_online_fallback bool

For source="rdkit", whether SMILES missing from the local database may be fetched from PubChem.

True
chunk_size int

How many CIDs to put in one online request.

PROPERTY_CHUNK_SIZE

Returns:

Type Description
DataFrame

A DataFrame with one row per distinct CID in the order requested. Columns are CID, Source and the descriptors. Source reads 'rdkit', 'online', or 'missing' for a CID no source knows; a descriptor with no value is NaN/None.

Raises:

Type Description
ValueError
PubChemError

If an online request could not be completed.

Examples:

>>> db = PubChemID()
>>> db.descriptors_table([2244, 702], ['TPSA'])
    CID Source   TPSA
0  2244  rdkit  63.60
1   702  rdkit  20.23
Source code in src/provesid/pubchem_id.py
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
def descriptors_table(self, cids: List[Union[int, str]],
                      descriptors: Optional[List[str]] = None,
                      source: str = 'rdkit',
                      use_online_fallback: bool = True,
                      chunk_size: int = PROPERTY_CHUNK_SIZE) -> 'pd.DataFrame':
    """
    Computed molecular descriptors for many compounds, as a DataFrame.

    Same lookup as
    [`descriptors_for_cids`][provesid.pubchem_id.PubChemID.descriptors_for_cids],
    with a row for every CID asked about, so the frame joins safely against
    the caller's own table. Because the RDKit and PubChem columns share
    names wherever the quantity is the same, two tables built with each
    source line up column for column, apart from ``MolLogP`` / ``XLogP``
    and ``Complexity``.

    Args:
        cids: PubChem Compound IDs. Duplicates are collapsed.
        descriptors: Names to compute; defaults to every descriptor the
            source has.
        source: ``'rdkit'`` or ``'pubchem'``.
        use_online_fallback: For ``source="rdkit"``, whether SMILES missing
            from the local database may be fetched from PubChem.
        chunk_size: How many CIDs to put in one online request.

    Returns:
        A DataFrame with one row per distinct CID in the order requested.
        Columns are ``CID``, ``Source`` and the descriptors. ``Source``
        reads ``'rdkit'``, ``'online'``, or ``'missing'`` for a CID no
        source knows; a descriptor with no value is NaN/None.

    Raises:
        ValueError: As for
            [`descriptors_for_cids`][provesid.pubchem_id.PubChemID.descriptors_for_cids].
        PubChemError: If an online request could not be completed.

    Examples:
        >>> db = PubChemID()
        >>> db.descriptors_table([2244, 702], ['TPSA'])
            CID Source   TPSA
        0  2244  rdkit  63.60
        1   702  rdkit  20.23
    """
    names = _check_descriptor_names(descriptors, source)
    rows = self.descriptors_for_cids(cids, names, source=source,
                                     use_online_fallback=use_online_fallback,
                                     chunk_size=chunk_size)
    return self._table(cids, rows, names)
provenance()

Where this database came from and how it was built.

A database built by provesid.pubchem_ftp.build_pubchem_id_db records its PubChem release, the snapshot's timestamp, the URL and MD5 of every source file, the row counts and the build time. That is what makes a lookup against it citable: the release pins down exactly which state of PubChem answered.

Returns:

Type Description
Dict[str, Any]

A dict of the provenance table's entries, plus files: one dict per source file with file, url, md5, bytes, lines_read and rows_kept. Empty for a database made before provenance was recorded --- every Zenodo copy so far.

Examples:

>>> db = PubChemID()
>>> db.provenance()["release"]
'2026-09-01'
Source code in src/provesid/pubchem_id.py
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
def provenance(self) -> Dict[str, Any]:
    """
    Where this database came from and how it was built.

    A database built by
    [`provesid.pubchem_ftp.build_pubchem_id_db`][provesid.pubchem_ftp.build_pubchem_id_db]
    records its PubChem release, the snapshot's timestamp, the URL and MD5
    of every source file, the row counts and the build time. That is what
    makes a lookup against it citable: the release pins down exactly which
    state of PubChem answered.

    Returns:
        A dict of the ``provenance`` table's entries, plus ``files``: one
        dict per source file with ``file``, ``url``, ``md5``, ``bytes``,
        ``lines_read`` and ``rows_kept``. Empty for a database made before
        provenance was recorded --- every Zenodo copy so far.

    Examples:
        >>> db = PubChemID()                                  # doctest: +SKIP
        >>> db.provenance()["release"]                        # doctest: +SKIP
        '2026-09-01'
    """
    tables = {row[0] for row in self.conn.execute(
        "SELECT name FROM sqlite_master WHERE type = 'table'")}
    if "provenance" not in tables:
        return {}
    record: Dict[str, Any] = dict(
        self.conn.execute("SELECT key, value FROM provenance").fetchall())
    record["files"] = [dict(row) for row in self.conn.execute(
        "SELECT * FROM provenance_files ORDER BY rowid")]
    return record
xrefs(cid)

Identifiers other databases give this compound, as PubChem links them.

PubChem publishes these links itself, in the same file the CAS numbers come from, so they cost nothing to keep: DSSTox substance IDs (dtxsid), ChEBI IDs, ChEMBL IDs, EC numbers and UNIIs --- see provesid.pubchem_ftp.XREF_TYPES.

Parameters:

Name Type Description Default
cid Union[int, str]

PubChem Compound ID.

required

Returns:

Type Description
Dict[str, List[str]]

A dict from source ("dtxsid", "chebi", "chembl", "ec", "unii") to that source's identifiers for the compound, sorted. Sources with none are left out, so a compound with no links returns {}.

Raises:

Type Description
ValueError

If cid is not an integer.

RuntimeError

If the database has no xrefs table --- a Zenodo copy. The message says how to build one that does.

Examples:

>>> db = PubChemID()
>>> db.xrefs(2244)
{'chebi': ['CHEBI:15365'], 'chembl': ['CHEMBL25'],
 'dtxsid': ['DTXSID5020108'], 'ec': ['200-064-1'],
 'unii': ['R16CO5Y76E']}
Source code in src/provesid/pubchem_id.py
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
def xrefs(self, cid: Union[int, str]) -> Dict[str, List[str]]:
    """
    Identifiers other databases give this compound, as PubChem links them.

    PubChem publishes these links itself, in the same file the CAS
    numbers come from, so they cost nothing to keep: DSSTox substance IDs
    (``dtxsid``), ChEBI IDs, ChEMBL IDs, EC numbers and UNIIs --- see
    [`provesid.pubchem_ftp.XREF_TYPES`][provesid.pubchem_ftp.XREF_TYPES].

    Args:
        cid: PubChem Compound ID.

    Returns:
        A dict from source (``"dtxsid"``, ``"chebi"``, ``"chembl"``,
        ``"ec"``, ``"unii"``) to that source's identifiers for the
        compound, sorted. Sources with none are left out, so a compound
        with no links returns ``{}``.

    Raises:
        ValueError: If ``cid`` is not an integer.
        RuntimeError: If the database has no ``xrefs`` table --- a Zenodo
            copy. The message says how to build one that does.

    Examples:
        >>> db = PubChemID()                                  # doctest: +SKIP
        >>> db.xrefs(2244)                                    # doctest: +SKIP
        {'chebi': ['CHEBI:15365'], 'chembl': ['CHEMBL25'],
         'dtxsid': ['DTXSID5020108'], 'ec': ['200-064-1'],
         'unii': ['R16CO5Y76E']}
    """
    cid = self._coerce_cid(cid)
    tables = {row[0] for row in self.conn.execute(
        "SELECT name FROM sqlite_master WHERE type = 'table'")}
    if "xrefs" not in tables:
        raise RuntimeError(
            f"{self.db_path} has no cross-references. They exist only in a "
            "database built from PubChem's FTP site: "
            "provesid.pubchem_ftp.build_pubchem_id_db(force=True)."
        )
    found: Dict[str, List[str]] = {}
    for source, identifier in self.conn.execute(
            "SELECT source, identifier FROM xrefs WHERE cid = ? "
            "ORDER BY source, identifier", (cid,)):
        found.setdefault(source, []).append(identifier)
    return found
get_stats()

Get database statistics.

Returns:

Type Description
dict

total_compounds, total_cas_numbers (rows in the CAS table), compounds_with_cas, total_synonyms, compounds_with_inchikey, database_path and database_size_mb. The counts depend on the release.

Examples:

>>> db = PubChemID()
>>> stats = db.get_stats()
>>> print(f"Total compounds: {stats['total_compounds']:,}")
Total compounds: 1,589,910
>>> stats['compounds_with_cas'] <= stats['total_compounds']
True
Source code in src/provesid/pubchem_id.py
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
def get_stats(self) -> Dict[str, int]:
    """
    Get database statistics.

    Returns:
        (dict): ``total_compounds``, ``total_cas_numbers`` (rows in the CAS
        table), ``compounds_with_cas``, ``total_synonyms``,
        ``compounds_with_inchikey``, ``database_path`` and
        ``database_size_mb``. The counts depend on the release.

    Examples:
        >>> db = PubChemID()
        >>> stats = db.get_stats()
        >>> print(f"Total compounds: {stats['total_compounds']:,}")  # doctest: +SKIP
        Total compounds: 1,589,910
        >>> stats['compounds_with_cas'] <= stats['total_compounds']
        True
    """
    cursor = self.conn.cursor()

    cursor.execute("SELECT COUNT(*) FROM compounds")
    total_compounds = cursor.fetchone()[0]

    cursor.execute("SELECT COUNT(*) FROM cas_numbers")
    total_cas = cursor.fetchone()[0]

    cursor.execute("SELECT COUNT(DISTINCT cid) FROM cas_numbers")
    compounds_with_cas = cursor.fetchone()[0]

    cursor.execute("SELECT COUNT(*) FROM synonyms")
    total_synonyms = cursor.fetchone()[0]

    cursor.execute("SELECT COUNT(*) FROM compounds WHERE inchikey IS NOT NULL AND inchikey != ''")
    compounds_with_inchikey = cursor.fetchone()[0]

    return {
        'total_compounds': total_compounds,
        'total_cas_numbers': total_cas,
        'compounds_with_cas': compounds_with_cas,
        'total_synonyms': total_synonyms,
        'compounds_with_inchikey': compounds_with_inchikey,
        'database_path': self.db_path,
        'database_size_mb': os.path.getsize(self.db_path) / (1024**2)
    }

Functions:

rdkit_descriptors(smiles, descriptors=None)

Compute molecular descriptors for a structure with RDKit.

This is what PubChemID.descriptors runs on each stored SMILES, exposed for structures that are not in PubChem. No network; about half a millisecond per molecule, half of it the logP.

The numbers are RDKit's, and they are not always PubChem's. PubChem computes its descriptors with Cactvs, which counts differently. Against PubChem's own values for 20 000 random CAS-bearing compounds, measured on 2026-09-21:

======================== ================================================== HeavyAtomCount identical for all Charge identical for all HBondDonorCount identical for 94% RotatableBondCount identical for 74%: Cactvs counts, for instance, the bond to a CF3 group TPSA identical for 70% HBondAcceptorCount identical for 63%: Cactvs counts, for instance, fluorine and halide counter-ions MolLogP Crippen's model, not XLogP3: within 0.5 of PubChem's XLogP for 62%, median gap 0.37 ======================== ==================================================

Parameters:

Name Type Description Default
smiles str

The structure, as SMILES.

required
descriptors Optional[List[str]]

Names from RDKIT_DESCRIPTORS to compute. Defaults to all of them.

None

Returns:

Type Description
Optional[Dict[str, Any]]

A dict from descriptor name to value, in the order requested, or None when RDKit cannot parse smiles. MolLogP and TPSA are floats, the rest ints.

Raises:

Type Description
ValueError

If a name is not in RDKIT_DESCRIPTORS. The message says where to get it instead, for PubChem's XLogP and Complexity.

Examples:

>>> rdkit_descriptors("CC(=O)OC1=CC=CC=C1C(=O)O", ["TPSA", "HBondDonorCount"])
{'TPSA': 63.6, 'HBondDonorCount': 1}
>>> rdkit_descriptors("not a molecule") is None
True
Source code in src/provesid/pubchem_id.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
def rdkit_descriptors(smiles: str,
                      descriptors: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
    """
    Compute molecular descriptors for a structure with RDKit.

    This is what
    [`PubChemID.descriptors`][provesid.pubchem_id.PubChemID.descriptors] runs
    on each stored SMILES, exposed for structures that are not in PubChem. No
    network; about half a millisecond per molecule, half of it the logP.

    The numbers are RDKit's, and they are not always PubChem's. PubChem
    computes its descriptors with Cactvs, which counts differently. Against
    PubChem's own values for 20 000 random CAS-bearing compounds, measured on
    2026-09-21:

    ========================  ==================================================
    ``HeavyAtomCount``        identical for all
    ``Charge``                identical for all
    ``HBondDonorCount``       identical for 94%
    ``RotatableBondCount``    identical for 74%: Cactvs counts, for instance,
                              the bond to a CF3 group
    ``TPSA``                  identical for 70%
    ``HBondAcceptorCount``    identical for 63%: Cactvs counts, for instance,
                              fluorine and halide counter-ions
    ``MolLogP``               Crippen's model, not XLogP3: within 0.5 of
                              PubChem's ``XLogP`` for 62%, median gap 0.37
    ========================  ==================================================

    Args:
        smiles: The structure, as SMILES.
        descriptors: Names from
            [`RDKIT_DESCRIPTORS`][provesid.pubchem_id.RDKIT_DESCRIPTORS] to
            compute. Defaults to all of them.

    Returns:
        A dict from descriptor name to value, in the order requested, or None
        when RDKit cannot parse ``smiles``. ``MolLogP`` and ``TPSA`` are
        floats, the rest ints.

    Raises:
        ValueError: If a name is not in
            [`RDKIT_DESCRIPTORS`][provesid.pubchem_id.RDKIT_DESCRIPTORS]. The
            message says where to get it instead, for PubChem's ``XLogP`` and
            ``Complexity``.

    Examples:
        >>> rdkit_descriptors("CC(=O)OC1=CC=CC=C1C(=O)O", ["TPSA", "HBondDonorCount"])
        {'TPSA': 63.6, 'HBondDonorCount': 1}
        >>> rdkit_descriptors("not a molecule") is None
        True
    """
    from rdkit import Chem, rdBase

    names = _check_descriptor_names(descriptors, 'rdkit')
    with rdBase.BlockLogs():
        mol = Chem.MolFromSmiles(smiles) if smiles else None
    if mol is None:
        return None
    functions = _rdkit_descriptor_functions()
    return {name: functions[name](mol) for name in names}

provesid.pubchem_ftp

Build the PubChem identifier database from PubChem's own FTP files.

PubChemID answers CAS, name, InChIKey and formula lookups from one SQLite file, pubchem_id.db. That file used to come from a manual pipeline: someone downloaded a CSV from PubChem's classification browser, pulled CAS numbers out of its free-text synonym column with a regular expression, and uploaded the result to Zenodo. Nothing recorded which PubChem release it came from, and 17 379 of its CAS numbers --- 1.25% --- fail the CAS check digit, because \d{2,7}-\d{2}-\d matches plenty of things that are not registry numbers.

This module builds the same database from Compound/Extras/ on PubChem's FTP site, in one call, from a dated monthly snapshot:

  • Scope comes from CID-Identifiers.tsv.gz, PubChem's curated mapping of compounds to third-party identifiers. Its CAS rows, each checked with provesid.utils.check_CASRN, decide which compounds go in --- about 1.43 M, with 123 CAS rows rejected where the regex let 17 379 through.
  • Columns come from one file each: title, formula and masses, isomeric SMILES, IUPAC name, InChI and InChIKey, creation date, and the filtered synonym list.
  • Cross-references --- DTXSID, ChEBI, ChEMBL, EC and UNII --- come from the same identifier file at no extra cost, and go into an xrefs table.
  • Provenance is written into the database itself: the release, every source file's URL and MD5, the row counts and the build time. A database on disk can always say where it came from.

The files are processed one at a time --- downloaded, streamed, filtered to the compounds in scope and deleted --- so the disk needed at any moment is the database plus the largest single file (7.4 GB, CID-InChI-Key.gz), not the 15.4 GB total.

The eight computed descriptors of the old database (XLogP, TPSA, complexity, charge and the four counts) are not in any of these files and are not stored: they are properties of the structure rather than data about the substance, and belong to an on-demand calculation instead.

Examples:

>>> from provesid.pubchem_ftp import build_pubchem_id_db, list_releases
>>> list_releases()
['2026-09-01', '2026-08-01', '2026-07-01', '2026-06-01', 'current']
>>> build_pubchem_id_db()
'/home/me/.local/share/provesid/pubchem_id.db'

Attributes

FTP_ROOT module-attribute

Root of PubChem's compound files. The HTTPS mirror of the FTP site answers Range requests and publishes an .md5 beside every file, which is what download_file needs to resume and verify.

LATEST module-attribute

Release name meaning "the newest monthly snapshot", the default.

CURRENT module-attribute

Release name meaning PubChem's rolling Compound/Extras/, regenerated with every dump. Fresher than any snapshot, but not reproducible: the files can change between two builds, or during one.

BUILDER_VERSION module-attribute

Version of the schema and the procedure this module writes, recorded in every database it builds. Bump it when either changes.

DOWNLOAD_DIRNAME module-attribute

Default name of the directory, beside the database, that source files are downloaded into, one subdirectory per release.

XREF_TYPES module-attribute

Identifier types from CID-Identifiers.tsv.gz stored in xrefs, mapped to the short name the table uses. These are the identifiers Search otherwise reconciles across sources by matching structures; PubChem publishes the links directly.

ATOMIC_WEIGHTS module-attribute

Standard atomic weights used for mw, by element symbol. Elements missing here (the radioactive ones without a standard weight) fall back to RDKit's periodic table.

SOURCE_FILES module-attribute

Every file the builder can read, in the order it reads them. The identifier file comes first because it decides which compounds are in scope; the rest are filtered by that decision.

Classes

SourceFile dataclass

One file of Compound/Extras/ and where its fields go.

Attributes:

Name Type Description
key str

Short name, used in logs and in the provenance_files table.

filename str

Name of the file under Extras/.

columns Tuple[str, ...]

compounds columns this file fills, in the order its converter returns them. Empty for the files that fill other tables.

fields int

Tab-separated fields after the CID on each line. Usually one per column; CID-Mass.gz has three and fills four, because the molecular weight is computed from its formula.

approx_bytes int

Compressed size in the 2026-09-01 snapshot, for the estimate a user sees before the build starts. Later snapshots are a little larger.

Examples:

>>> [source.key for source in SOURCE_FILES]
['identifiers', 'date', 'mass', 'smiles', 'title', 'iupac', 'inchi', 'synonyms']
>>> SOURCE_FILES[0].filename
'CID-Identifiers.tsv.gz'
Source code in src/provesid/pubchem_ftp.py
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
@dataclass(frozen=True)
class SourceFile:
    """
    One file of ``Compound/Extras/`` and where its fields go.

    Attributes:
        key: Short name, used in logs and in the ``provenance_files`` table.
        filename: Name of the file under ``Extras/``.
        columns: ``compounds`` columns this file fills, in the order its
            converter returns them. Empty for the files that fill other
            tables.
        fields: Tab-separated fields after the CID on each line. Usually one
            per column; ``CID-Mass.gz`` has three and fills four, because the
            molecular weight is computed from its formula.
        approx_bytes: Compressed size in the 2026-09-01 snapshot, for the
            estimate a user sees before the build starts. Later snapshots are
            a little larger.

    Examples:
        >>> [source.key for source in SOURCE_FILES]
        ['identifiers', 'date', 'mass', 'smiles', 'title', 'iupac', 'inchi', 'synonyms']
        >>> SOURCE_FILES[0].filename
        'CID-Identifiers.tsv.gz'
    """

    key: str
    filename: str
    columns: Tuple[str, ...]
    fields: int
    approx_bytes: int

Functions:

molecular_weight(formula)

Molecular weight of a PubChem molecular formula, in g/mol.

Computed with ATOMIC_WEIGHTS and rounded to two decimals. A charge suffix (+, -2) is ignored, as PubChem ignores it: the weight of an ion is the weight of its atoms.

The result agrees with PubChem's own MolecularWeight to the second decimal for most compounds, but PubChem rounds some weights more coarsely --- to one decimal, or to a whole number for compounds of lead --- and where it does, this is the more precise of the two.

A formula cannot describe isotopic labelling: PubChem writes chloroform-d as CHCl3. The builder corrects those compounds from their SMILES; this function cannot.

Parameters:

Name Type Description Default
formula str

A formula as PubChem writes it, e.g. "C9H8O4" or "C9H18NO4+".

required

Returns:

Type Description
Optional[float]

The weight, or None when the formula is empty, malformed or names an element with no known weight.

Examples:

>>> molecular_weight("C9H8O4")
180.16
>>> molecular_weight("C9H18NO4+")
204.24
>>> molecular_weight("not a formula") is None
True
Source code in src/provesid/pubchem_ftp.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def molecular_weight(formula: str) -> Optional[float]:
    """
    Molecular weight of a PubChem molecular formula, in g/mol.

    Computed with [`ATOMIC_WEIGHTS`][provesid.pubchem_ftp.ATOMIC_WEIGHTS] and
    rounded to two decimals. A charge suffix (``+``, ``-2``) is ignored, as
    PubChem ignores it: the weight of an ion is the weight of its atoms.

    The result agrees with PubChem's own ``MolecularWeight`` to the second
    decimal for most compounds, but PubChem rounds some weights more coarsely
    --- to one decimal, or to a whole number for compounds of lead --- and
    where it does, this is the more precise of the two.

    A formula cannot describe isotopic labelling: PubChem writes
    chloroform-*d* as ``CHCl3``. The builder corrects those compounds from
    their SMILES; this function cannot.

    Args:
        formula: A formula as PubChem writes it, e.g. ``"C9H8O4"`` or
            ``"C9H18NO4+"``.

    Returns:
        The weight, or None when the formula is empty, malformed or names an
        element with no known weight.

    Examples:
        >>> molecular_weight("C9H8O4")
        180.16
        >>> molecular_weight("C9H18NO4+")
        204.24
        >>> molecular_weight("not a formula") is None
        True
    """
    if not formula or not _FORMULA_SHAPE.match(formula):
        return None
    body = re.sub(r"[+-]\d*$", "", formula)
    total = 0.0
    for symbol, count in _FORMULA_TOKEN.findall(body):
        weight = _atomic_weight(symbol)
        if weight is None:
            return None
        total += weight * (int(count) if count else 1)
    return _round_weight(total)

list_releases(*, base_url=None, session=None, timeout=30)

The PubChem releases a database can be built from, newest first.

Monthly snapshots live under Compound/Monthly/YYYY-MM-01/ and are frozen once published, so a database built from one is reproducible and can be cited. PubChem keeps the last few months. "current" --- the rolling Compound/Extras/ --- is always listed last.

Parameters:

Name Type Description Default
base_url Optional[str]

PubChem compound root. Defaults to FTP_ROOT.

None
session Optional[Session]

requests.Session to fetch through.

None
timeout float

Seconds to wait for the listing.

30

Returns:

Type Description
List[str]

Snapshot dates as YYYY-MM-DD strings, newest first, followed by "current".

Raises:

Type Description
DownloadError

If the listing cannot be fetched.

Examples:

>>> list_releases()
['2026-09-01', '2026-08-01', '2026-07-01', '2026-06-01', 'current']
Source code in src/provesid/pubchem_ftp.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
def list_releases(*, base_url: Optional[str] = None,
                  session: Optional[requests.Session] = None,
                  timeout: float = 30) -> List[str]:
    """
    The PubChem releases a database can be built from, newest first.

    Monthly snapshots live under ``Compound/Monthly/YYYY-MM-01/`` and are
    frozen once published, so a database built from one is reproducible and
    can be cited. PubChem keeps the last few months. ``"current"`` --- the
    rolling ``Compound/Extras/`` --- is always listed last.

    Args:
        base_url: PubChem compound root. Defaults to
            [`FTP_ROOT`][provesid.pubchem_ftp.FTP_ROOT].
        session: ``requests.Session`` to fetch through.
        timeout: Seconds to wait for the listing.

    Returns:
        Snapshot dates as ``YYYY-MM-DD`` strings, newest first, followed by
        ``"current"``.

    Raises:
        DownloadError: If the listing cannot be fetched.

    Examples:
        >>> list_releases()                                  # doctest: +SKIP
        ['2026-09-01', '2026-08-01', '2026-07-01', '2026-06-01', 'current']
    """
    url = f"{_root(base_url)}/Monthly/"
    getter = session.get if session is not None else requests.get
    try:
        response = getter(url, timeout=timeout)
        response.raise_for_status()
    except requests.RequestException as exc:
        raise DownloadError(f"Could not list PubChem releases at {url}: {exc}",
                            url=url) from exc
    dates = sorted(set(_LISTED_RELEASE.findall(response.text)), reverse=True)
    return dates + [CURRENT]

resolve_release(release=LATEST, *, base_url=None, session=None)

Turn a release argument into a concrete release name.

Parameters:

Name Type Description Default
release str

"latest" for the newest monthly snapshot, "current" for the rolling dump, or a snapshot date such as "2026-09-01".

LATEST
base_url Optional[str]

PubChem compound root. Defaults to FTP_ROOT.

None
session Optional[Session]

requests.Session to fetch the listing through.

None

Returns:

Type Description
str

"current" or a YYYY-MM-DD date. "latest" costs one request for the listing; the other two cost none, so a date that PubChem no longer keeps is only discovered when its first file is requested.

Raises:

Type Description
ValueError

If release is none of the three forms.

DownloadError

If "latest" was asked for and the listing cannot be fetched, or lists no snapshot.

Examples:

>>> resolve_release("2026-09-01"), resolve_release("current")
('2026-09-01', 'current')
>>> resolve_release()
'2026-09-01'
>>> resolve_release("yesterday")
Traceback (most recent call last):
...
ValueError: release='yesterday' is not a PubChem release. ...
Source code in src/provesid/pubchem_ftp.py
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
def resolve_release(release: str = LATEST, *, base_url: Optional[str] = None,
                    session: Optional[requests.Session] = None) -> str:
    """
    Turn a release argument into a concrete release name.

    Args:
        release: ``"latest"`` for the newest monthly snapshot, ``"current"``
            for the rolling dump, or a snapshot date such as ``"2026-09-01"``.
        base_url: PubChem compound root. Defaults to
            [`FTP_ROOT`][provesid.pubchem_ftp.FTP_ROOT].
        session: ``requests.Session`` to fetch the listing through.

    Returns:
        ``"current"`` or a ``YYYY-MM-DD`` date. ``"latest"`` costs one request
        for the listing; the other two cost none, so a date that PubChem no
        longer keeps is only discovered when its first file is requested.

    Raises:
        ValueError: If ``release`` is none of the three forms.
        DownloadError: If ``"latest"`` was asked for and the listing cannot be
            fetched, or lists no snapshot.

    Examples:
        >>> resolve_release("2026-09-01"), resolve_release("current")
        ('2026-09-01', 'current')
        >>> resolve_release()                                  # doctest: +SKIP
        '2026-09-01'
        >>> resolve_release("yesterday")
        Traceback (most recent call last):
        ...
        ValueError: release='yesterday' is not a PubChem release. ...
    """
    if release == CURRENT or _RELEASE_PATTERN.match(release or ""):
        return release
    if release != LATEST:
        raise ValueError(
            f"release={release!r} is not a PubChem release. Use {LATEST!r}, "
            f"{CURRENT!r} or a snapshot date such as '2026-09-01' "
            f"(see provesid.pubchem_ftp.list_releases())."
        )
    snapshots = [name for name in list_releases(base_url=base_url, session=session)
                 if name != CURRENT]
    if not snapshots:
        raise DownloadError(f"No monthly snapshot is listed under {_root(base_url)}/Monthly/")
    return snapshots[0]

release_url(release, *, base_url=None)

URL of a concrete release's directory: Monthly/<date> or the root.

Parameters:

Name Type Description Default
release str

"current" or a snapshot date, as resolve_release returns.

required
base_url Optional[str]

PubChem compound root. Defaults to FTP_ROOT.

None

Returns:

Type Description
str

The directory URL, without a trailing slash.

Examples:

>>> release_url("2026-09-01")
'https://ftp.ncbi.nlm.nih.gov/pubchem/Compound/Monthly/2026-09-01'
>>> release_url("current")
'https://ftp.ncbi.nlm.nih.gov/pubchem/Compound'
Source code in src/provesid/pubchem_ftp.py
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
def release_url(release: str, *, base_url: Optional[str] = None) -> str:
    """
    URL of a concrete release's directory: ``Monthly/<date>`` or the root.

    Args:
        release: ``"current"`` or a snapshot date, as
            [`resolve_release`][provesid.pubchem_ftp.resolve_release] returns.
        base_url: PubChem compound root. Defaults to
            [`FTP_ROOT`][provesid.pubchem_ftp.FTP_ROOT].

    Returns:
        The directory URL, without a trailing slash.

    Examples:
        >>> release_url("2026-09-01")
        'https://ftp.ncbi.nlm.nih.gov/pubchem/Compound/Monthly/2026-09-01'
        >>> release_url("current")
        'https://ftp.ncbi.nlm.nih.gov/pubchem/Compound'
    """
    if release == CURRENT:
        return _root(base_url)
    return f"{_root(base_url)}/Monthly/{release}"

extras_url(release, *, base_url=None)

URL of the Extras/ directory of a concrete release.

Parameters:

Name Type Description Default
release str

"current" or a snapshot date, as resolve_release returns.

required
base_url Optional[str]

PubChem compound root. Defaults to FTP_ROOT.

None

Returns:

Type Description
str

The directory URL, without a trailing slash.

Examples:

>>> extras_url("2026-09-01")
'https://ftp.ncbi.nlm.nih.gov/pubchem/Compound/Monthly/2026-09-01/Extras'
>>> extras_url("current")
'https://ftp.ncbi.nlm.nih.gov/pubchem/Compound/Extras'
Source code in src/provesid/pubchem_ftp.py
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
def extras_url(release: str, *, base_url: Optional[str] = None) -> str:
    """
    URL of the ``Extras/`` directory of a concrete release.

    Args:
        release: ``"current"`` or a snapshot date, as
            [`resolve_release`][provesid.pubchem_ftp.resolve_release] returns.
        base_url: PubChem compound root. Defaults to
            [`FTP_ROOT`][provesid.pubchem_ftp.FTP_ROOT].

    Returns:
        The directory URL, without a trailing slash.

    Examples:
        >>> extras_url("2026-09-01")
        'https://ftp.ncbi.nlm.nih.gov/pubchem/Compound/Monthly/2026-09-01/Extras'
        >>> extras_url("current")
        'https://ftp.ncbi.nlm.nih.gov/pubchem/Compound/Extras'
    """
    return f"{release_url(release, base_url=base_url)}/Extras"

selected_files(*, include_inchi=True, include_synonyms=True)

The source files a build with these options reads, in reading order.

Parameters:

Name Type Description Default
include_inchi bool

Whether InChI and InChIKey come from PubChem's own file.

True
include_synonyms bool

Whether the synonym list is included.

True

Returns:

Type Description
List[SourceFile]

The SourceFile entries, a subset of SOURCE_FILES.

Examples:

>>> " ".join(f.key for f in selected_files(include_inchi=False))
'identifiers date mass smiles title iupac synonyms'
Source code in src/provesid/pubchem_ftp.py
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
def selected_files(*, include_inchi: bool = True,
                   include_synonyms: bool = True) -> List[SourceFile]:
    """
    The source files a build with these options reads, in reading order.

    Args:
        include_inchi: Whether InChI and InChIKey come from PubChem's own file.
        include_synonyms: Whether the synonym list is included.

    Returns:
        The [`SourceFile`][provesid.pubchem_ftp.SourceFile] entries, a subset
        of [`SOURCE_FILES`][provesid.pubchem_ftp.SOURCE_FILES].

    Examples:
        >>> " ".join(f.key for f in selected_files(include_inchi=False))
        'identifiers date mass smiles title iupac synonyms'
    """
    skip = set()
    if not include_inchi:
        skip.add("inchi")
    if not include_synonyms:
        skip.add("synonyms")
    return [source for source in SOURCE_FILES if source.key not in skip]

build_pubchem_id_db(db_path=None, *, release=LATEST, include_inchi=True, include_synonyms=True, keep_downloads=False, download_dir=None, force=False, progress=True, base_url=None, session=None)

Build pubchem_id.db from PubChem's FTP files.

Every compound carrying a valid CAS number in PubChem's curated identifier mapping is included, with its title, formula, molecular weight, exact and monoisotopic mass, isomeric SMILES, IUPAC name, InChI, InChIKey, creation date, synonyms, CAS numbers and cross-references to DSSTox, ChEBI, ChEMBL, EC and UNII.

The source files are handled one at a time: each is downloaded (resumably, and checked against the MD5 PubChem publishes beside it), streamed through once to keep only the compounds in scope, and deleted. The database is built at db_path + '.tmp' and moved into place only when it is complete, so a failed or interrupted build never touches an existing database. A rerun starts the database over, but resumes an interrupted download and reuses any file keep_downloads=True left behind once its MD5 checks out.

Measured costs, 2026-09-01 snapshot: 15.4 GB transferred (8.0 GB with include_inchi=False), a 2.5 GB database, and at most the database plus the 7.4 GB InChI file on disk at once. Reading and writing take about 12 minutes; the rest is the download, which is about 20 minutes at 12 MB/s and was five hours on a day PubChem served 0.85 MB/s. Memory stays under 300 MB.

Parameters:

Name Type Description Default
db_path Optional[str]

Where the database goes. Defaults to pubchem_id.db in the per-user dataset directory, where PubChemID looks for it.

None
release str

"latest" (default) for the newest monthly snapshot, a snapshot date such as "2026-09-01", or "current" for the rolling dump. A snapshot is frozen and makes the build reproducible; "current" is a day or so fresher but its files are regenerated in place and can change mid-build.

LATEST
include_inchi bool

Take InChI and InChIKey from PubChem's CID-InChI-Key.gz (default). False skips that 7.4 GB file and computes both from the SMILES with RDKit instead --- 7.4 GB less to download, but RDKit's InChI, not PubChem's, and a much longer build, since every structure is parsed.

True
include_synonyms bool

Include the synonym table, which search_by_name searches. False saves a 1 GB download and most of the database's size.

True
keep_downloads bool

Keep each source file after it has been read, in download_dir. A later build of the same release then reuses them without downloading again.

False
download_dir Optional[str]

Where source files are downloaded. Defaults to pubchem_ftp/<release>/ beside the database.

None
force bool

Replace a database already at db_path.

False
progress bool

Show progress bars.

True
base_url Optional[str]

PubChem compound root. Defaults to FTP_ROOT; a mirror, or a local server in a test, goes here.

None
session Optional[Session]

requests.Session to download through.

None

Returns:

Type Description
str

The path of the finished database.

Raises:

Type Description
FileExistsError

If a database is already at db_path and force is False.

ValueError

If release is not a release name.

DownloadError

If a file cannot be downloaded or fails its checksum.

Examples:

>>> from provesid.pubchem_ftp import build_pubchem_id_db
>>> build_pubchem_id_db(release="2026-09-01")
'/home/me/.local/share/provesid/pubchem_id.db'
>>> build_pubchem_id_db("/data/ids.db", include_synonyms=False,
...                     keep_downloads=True)
'/data/ids.db'
Source code in src/provesid/pubchem_ftp.py
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
def build_pubchem_id_db(
    db_path: Optional[str] = None,
    *,
    release: str = LATEST,
    include_inchi: bool = True,
    include_synonyms: bool = True,
    keep_downloads: bool = False,
    download_dir: Optional[str] = None,
    force: bool = False,
    progress: bool = True,
    base_url: Optional[str] = None,
    session: Optional[requests.Session] = None,
) -> str:
    """
    Build ``pubchem_id.db`` from PubChem's FTP files.

    Every compound carrying a valid CAS number in PubChem's curated identifier
    mapping is included, with its title, formula, molecular weight, exact and
    monoisotopic mass, isomeric SMILES, IUPAC name, InChI, InChIKey, creation
    date, synonyms, CAS numbers and cross-references to DSSTox, ChEBI, ChEMBL,
    EC and UNII.

    The source files are handled one at a time: each is downloaded (resumably,
    and checked against the MD5 PubChem publishes beside it), streamed through
    once to keep only the compounds in scope, and deleted. The database is
    built at ``db_path + '.tmp'`` and moved into place only when it is
    complete, so a failed or interrupted build never touches an existing
    database. A rerun starts the database over, but resumes an interrupted
    download and reuses any file ``keep_downloads=True`` left behind once its
    MD5 checks out.

    Measured costs, 2026-09-01 snapshot: 15.4 GB transferred (8.0 GB with
    ``include_inchi=False``), a 2.5 GB database, and at most the database plus
    the 7.4 GB InChI file on disk at once. Reading and writing take about 12
    minutes; the rest is the download, which is about 20 minutes at 12 MB/s
    and was five hours on a day PubChem served 0.85 MB/s. Memory stays under
    300 MB.

    Args:
        db_path: Where the database goes. Defaults to ``pubchem_id.db`` in the
            per-user dataset directory, where
            [`PubChemID`][provesid.pubchem_id.PubChemID] looks for it.
        release: ``"latest"`` (default) for the newest monthly snapshot, a
            snapshot date such as ``"2026-09-01"``, or ``"current"`` for the
            rolling dump. A snapshot is frozen and makes the build
            reproducible; ``"current"`` is a day or so fresher but its files
            are regenerated in place and can change mid-build.
        include_inchi: Take InChI and InChIKey from PubChem's
            ``CID-InChI-Key.gz`` (default). False skips that 7.4 GB file and
            computes both from the SMILES with RDKit instead --- 7.4 GB less to
            download, but RDKit's InChI, not PubChem's, and a much longer
            build, since every structure is parsed.
        include_synonyms: Include the synonym table, which
            [`search_by_name`][provesid.pubchem_id.PubChemID.search_by_name]
            searches. False saves a 1 GB download and most of the database's
            size.
        keep_downloads: Keep each source file after it has been read, in
            ``download_dir``. A later build of the same release then reuses
            them without downloading again.
        download_dir: Where source files are downloaded. Defaults to
            ``pubchem_ftp/<release>/`` beside the database.
        force: Replace a database already at ``db_path``.
        progress: Show progress bars.
        base_url: PubChem compound root. Defaults to
            [`FTP_ROOT`][provesid.pubchem_ftp.FTP_ROOT]; a mirror, or a local
            server in a test, goes here.
        session: ``requests.Session`` to download through.

    Returns:
        The path of the finished database.

    Raises:
        FileExistsError: If a database is already at ``db_path`` and ``force``
            is False.
        ValueError: If ``release`` is not a release name.
        DownloadError: If a file cannot be downloaded or fails its checksum.

    Examples:
        >>> from provesid.pubchem_ftp import build_pubchem_id_db
        >>> build_pubchem_id_db(release="2026-09-01")          # doctest: +SKIP
        '/home/me/.local/share/provesid/pubchem_id.db'
        >>> build_pubchem_id_db("/data/ids.db", include_synonyms=False,
        ...                     keep_downloads=True)            # doctest: +SKIP
        '/data/ids.db'
    """
    if db_path is None:
        db_path = os.path.join(user_dataset_path(), "pubchem_id.db")
    db_path = os.path.abspath(os.path.expanduser(db_path))
    if os.path.exists(db_path) and not force:
        raise FileExistsError(
            f"A database already exists at {db_path}. Pass force=True to rebuild it."
        )

    release = resolve_release(release, base_url=base_url, session=session)
    source_url = extras_url(release, base_url=base_url)
    if download_dir is None:
        download_dir = os.path.join(os.path.dirname(db_path), DOWNLOAD_DIRNAME, release)
    os.makedirs(os.path.dirname(db_path), exist_ok=True)
    os.makedirs(download_dir, exist_ok=True)
    files = selected_files(include_inchi=include_inchi, include_synonyms=include_synonyms)

    logger.info(
        "Building %s from PubChem release %s: %d files, about %.1f GB to download",
        db_path, release, len(files), sum(f.approx_bytes for f in files) / 1e9,
    )

    build = _Build(
        path=db_path + ".tmp",
        release=release,
        source_url=source_url,
        download_dir=download_dir,
        keep_downloads=keep_downloads,
        progress=progress,
        session=session,
    )
    try:
        build.run(files, derive_inchi=not include_inchi,
                  timestamp=_release_timestamp(release, base_url, session))
    except BaseException:
        build.discard()
        raise

    os.replace(build.path, db_path)
    if not keep_downloads:
        _remove_empty_directories(download_dir, stop_at=os.path.dirname(db_path))
    logger.info("PubChem ID database ready at %s (%.2f GB)",
                db_path, os.path.getsize(db_path) / 1e9)
    return db_path