Skip to content

OPSIN

IUPAC name to structure: OPSIN asks the online service, PYOPSIN runs the OPSIN jar locally. See the OPSIN tutorial.

provesid.opsin

OPSIN, which turns systematic chemical names into structures.

Two ways in. OPSIN calls the hosted web service at EBI, one request per name, with pacing, retries and caching. PYOPSIN runs the same parser locally through py2opsin, which bundles OPSIN's jar and needs a Java runtime but no network, and parses a list in one JVM start.

OPSIN reads names; it does not look them up. "ethanol" and "2-acetyloxybenzoic acid" parse, while a trade name such as "aspirin" does not.

Examples:

>>> from provesid.opsin import OPSIN, PYOPSIN
>>> PYOPSIN().get_std_inchikey("ethanol")
'LFQSCWFLJHTTHZ-UHFFFAOYSA-N'
>>> OPSIN().get_id("ethanol")["smiles"]
'C(C)O'

Attributes

OPSIN_MIN_INTERVAL module-attribute

Seconds between two requests to OPSIN. The service is a name parser rather than a database lookup, and EBI publishes no per-IP figure for it, so this is politeness: ten requests a second. Note that OPSIN and ChEBI are both served from www.ebi.ac.uk, so they share a pacing clock and ten per second is the budget for the two of them together --- which is the point of keying the clock by host.

Classes

OPSINError

Bases: ServiceError

Base exception for OPSIN web-service failures.

The methods below do not let it reach the caller: they report a failure in the status key of the dict they return, which is the contract they have always had.

Examples:

>>> issubclass(OPSINTimeoutError, OPSINError)
True
Source code in src/provesid/opsin.py
48
49
50
51
52
53
54
55
56
57
58
59
60
class OPSINError(ServiceError):
    """
    Base exception for OPSIN web-service failures.

    The methods below do not let it reach the caller: they report a failure in
    the ``status`` key of the dict they return, which is the contract they have
    always had.

    Examples:
        >>> issubclass(OPSINTimeoutError, OPSINError)
        True
    """
    pass

OPSINNotFoundError

Bases: OPSINError, NotFoundError

Raised for an absence OPSIN reports with no usable body.

Rare in practice: OPSIN explains an unparseable name in the body of its 404, and opsin_classify treats that body as the answer rather than as absence.

Examples:

>>> issubclass(OPSINNotFoundError, NotFoundError)
True
Source code in src/provesid/opsin.py
63
64
65
66
67
68
69
70
71
72
73
74
75
class OPSINNotFoundError(OPSINError, NotFoundError):
    """
    Raised for an absence OPSIN reports with no usable body.

    Rare in practice: OPSIN explains an unparseable name in the body of its
    404, and [`opsin_classify`][provesid.opsin.opsin_classify] treats that body
    as the answer rather than as absence.

    Examples:
        >>> issubclass(OPSINNotFoundError, NotFoundError)
        True
    """
    pass

OPSINTimeoutError

Bases: OPSINError, ServiceTimeoutError

Every attempt timed out or the connection could not be made.

Examples:

>>> issubclass(OPSINTimeoutError, ServiceTimeoutError)
True
Source code in src/provesid/opsin.py
78
79
80
81
82
83
84
85
86
class OPSINTimeoutError(OPSINError, ServiceTimeoutError):
    """
    Every attempt timed out or the connection could not be made.

    Examples:
        >>> issubclass(OPSINTimeoutError, ServiceTimeoutError)
        True
    """
    pass

OPSIN

A Python interface to the OPSIN name-to-structure web service.

OPSIN turns a systematic IUPAC name into a structure. This class calls the hosted service; PYOPSIN runs the same parser locally through py2opsin, which is faster and works offline, and is the better choice for anything but a handful of names.

Attributes:

Name Type Description
base_url

The OPSIN web service endpoint.

use_cache

Whether lookups are served from the cache.

Examples:

>>> OPSIN().base_url
'https://www.ebi.ac.uk/opsin/ws/'
Source code in src/provesid/opsin.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
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
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
class OPSIN:
    """
    A Python interface to the OPSIN name-to-structure web service.

    OPSIN turns a systematic IUPAC name into a structure. This class calls the
    hosted service; [`PYOPSIN`][provesid.opsin.PYOPSIN] runs the same parser
    locally through ``py2opsin``, which is faster and works offline, and is the
    better choice for anything but a handful of names.

    Attributes:
        base_url: The OPSIN web service endpoint.
        use_cache: Whether lookups are served from the cache.

    Examples:
        >>> OPSIN().base_url
        'https://www.ebi.ac.uk/opsin/ws/'
    """

    def __init__(self, use_cache: bool = True):
        """
        Initialize the OPSIN web-service client.

        Args:
            use_cache: Whether to use the cache for lookups (default True).
                When False, skips the cache lookup but still stores results.

        Examples:
            >>> OPSIN(use_cache=False).use_cache
            False
        """
        # This module used to name the Cambridge host, opsin.ch.cam.ac.uk,
        # which answers every request with a 301 to here --- so asking here
        # directly saves a redirect per name. Verified live on 2026-09-19.
        self.base_url = "https://www.ebi.ac.uk/opsin/ws/"
        self.use_cache = use_cache
        self.logger = logging.getLogger(__name__)

        # One shared transport. Before this the module had no pacing, no retry
        # and no timeout handling: a momentary 503 reached the caller as a raw
        # KeyError out of the status-code table.
        self._http = HTTPClient(
            min_interval=OPSIN_MIN_INTERVAL,
            classify=opsin_classify,
            error_cls=OPSINError,
            not_found_cls=OPSINNotFoundError,
            timeout_cls=OPSINTimeoutError,
            pace_host=self.base_url,
            logger=self.logger,
        )

    CACHE_SCHEMA_VERSION = 2
    """Bumped whenever an entry written by an earlier version must not be
    served. Version 2 is this change: the old code cached *failures*, so a
    name that hit one momentary 503 is on disk as a permanent ``"FAILURE"``
    for a name OPSIN parses perfectly well --- and every cached failure
    carries an empty ``message``, because the old code never read the body
    that explains it. Neither is fixable in place, so the old entries are
    made unreachable instead.
    """

    def __cache_key__(self) -> tuple:
        """
        Identify this client for cache-key purposes.

        Without this the key is the class name alone, which is stable across
        processes --- what [`stable_key_part`][provesid.cache.stable_key_part]
        is for --- but carries no way to retire an entry whose content is now
        known to be wrong. The schema version is that way.

        Returns:
            Tuple of the class path, the configured base URL and
            [`CACHE_SCHEMA_VERSION`][provesid.opsin.OPSIN.CACHE_SCHEMA_VERSION].

        Examples:
            >>> OPSIN().__cache_key__()
            ('provesid.opsin.OPSIN', 'https://www.ebi.ac.uk/opsin/ws/', 2)
        """
        return ("provesid.opsin.OPSIN", self.base_url,
                self.CACHE_SCHEMA_VERSION)

    def clear_cache(self):
        """
        Delete every cached OPSIN answer, in memory and on disk.

        The same as ``provesid.clear_cache(service='opsin')``.

        Examples:
            >>> opsin = OPSIN()
            >>> opsin.clear_cache()
            >>> opsin.get_cache_info()['file_count']
            0
        """
        clear_cache(service='opsin')

    def get_cache_info(self):
        """
        Size and location of the OPSIN cache.

        Returns:
            The statistics
            [`provesid.cache.get_cache_info`][provesid.cache.get_cache_info]
            reports for
                ``service='opsin'``.

        Examples:
            >>> OPSIN().get_cache_info()['cache_directory'].endswith('opsin')
            True
        """
        return get_cache_info(service='opsin')

    @cached(service='opsin', skip_if=_not_resolved)
    def get_id(self, iupac_name: str, timeout=30):
        """
        Return the structure OPSIN parses out of an IUPAC name.

        Adapted from the IUPAC WorldFAIR book:
        https://iupac.github.io/WFChemCookbook/tools/opsin_api_jn.html

        Args:
            iupac_name: The systematic name to parse.
            timeout: Request timeout in seconds.

        Returns:
            A dict in the shape of `_empty_res`. ``status`` is OPSIN's
            own, ``"SUCCESS"`` or ``"FAILURE"``, and on a failure ``message``
            carries OPSIN's explanation of which part of the name it could not
            read. On a transport failure ``status`` is ``"FAILURE"`` and
            ``message`` describes the failure; nothing is raised, and nothing
            is cached. A blank name is a ``"FAILURE"`` without a request:
            sent, it would leave the URL ending in ``ws/.json``, and OPSIN
            would answer about the name "ws".

        Examples:
            >>> OPSIN().get_id("ethanol")["smiles"]        # doctest: +SKIP
            'C(C)O'
            >>> OPSIN().get_id("notachemical")["status"]   # doctest: +SKIP
            'FAILURE'
            >>> OPSIN().get_id("  ")["message"]
            'empty name'
        """
        res = self._empty_res()
        res["iupac_name"] = iupac_name
        if not iupac_name.strip():
            res["status"] = "FAILURE"
            res["message"] = "empty name"
            return res

        apiurl = self.base_url + iupac_name + '.json'

        try:
            jsondata = self._http.get_json(apiurl, timeout=timeout)
        except OPSINError as e:
            # A parse failure is not an error --- opsin_classify reads that out
            # of the 404 body. Reaching here means the service itself failed.
            res["status"] = "FAILURE"
            res["message"] = str(e)
            self.logger.warning(f"OPSIN request failed for {iupac_name}: {e}")
            return res

        res["status"] = jsondata.get("status", "FAILURE")
        res["message"] = jsondata.get("message", "")
        if res["status"] != "SUCCESS":
            return res

        res["smiles"] = jsondata["smiles"]
        res["stdinchi"] = jsondata["stdinchi"]
        res["stdinchikey"] = jsondata["stdinchikey"]
        res["inchi"] = jsondata["inchi"]
        return res

    @staticmethod
    def _empty_res():
        """
        create an empty response dictionary of the following format:
        {
            "status": "SUCCESS",
            "message": "",
            "inchi": "InChI=1/C2H2Cl4/c3-1(4)2(5)6/h1-2H",
            "stdinchi": "InChI=1S/C2H2Cl4/c3-1(4)2(5)6/h1-2H",
            "stdinchikey": "QPFMBZIOSGYJDE-UHFFFAOYSA-N",
            "smiles": "ClC(C(Cl)Cl)Cl"
        }
        """
        return {
            "iupac_name": "",
            "status": "",
            "message": "",
            "inchi": "",
            "stdinchi": "",
            "stdinchikey": "",
            "smiles": ""
        }

    @cached(service='opsin', skip_if=_any_not_resolved)
    def get_id_from_list(self, iupac_names: list, timeout=30, pause_time=0.5):
        """
        Parse a list of IUPAC names, one request each.

        Args:
            iupac_names: The systematic names to parse.
            timeout: Per-request timeout in seconds.
            pause_time: Seconds to sleep after each name, on top of the pacing
                the transport already applies.

        Returns:
            One dict per input name, in order, each in the shape
            [`get_id`][provesid.opsin.OPSIN.get_id] returns. A name OPSIN could
            not parse is a ``"FAILURE"`` entry carrying OPSIN's explanation in
            ``message``, not a missing row.

        Examples:
            >>> records = OPSIN().get_id_from_list(["ethanol", "benzene"])  # doctest: +SKIP
            >>> [(r["iupac_name"], r["status"]) for r in records]           # doctest: +SKIP
            [('ethanol', 'SUCCESS'), ('benzene', 'SUCCESS')]
        """
        results = []
        for iupac_name in iupac_names:
            res = self.get_id(iupac_name, timeout)
            if res["status"] != "SUCCESS":
                self.logger.warning(
                    f"Failed to get ID for {iupac_name}: {res['message']}"
                )
            results.append(res)
            time.sleep(pause_time)
        return results
Attributes
CACHE_SCHEMA_VERSION class-attribute instance-attribute

Bumped whenever an entry written by an earlier version must not be served. Version 2 is this change: the old code cached failures, so a name that hit one momentary 503 is on disk as a permanent "FAILURE" for a name OPSIN parses perfectly well --- and every cached failure carries an empty message, because the old code never read the body that explains it. Neither is fixable in place, so the old entries are made unreachable instead.

Methods:
__init__(use_cache=True)

Initialize the OPSIN web-service client.

Parameters:

Name Type Description Default
use_cache bool

Whether to use the cache for lookups (default True). When False, skips the cache lookup but still stores results.

True

Examples:

>>> OPSIN(use_cache=False).use_cache
False
Source code in src/provesid/opsin.py
196
197
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
def __init__(self, use_cache: bool = True):
    """
    Initialize the OPSIN web-service client.

    Args:
        use_cache: Whether to use the cache for lookups (default True).
            When False, skips the cache lookup but still stores results.

    Examples:
        >>> OPSIN(use_cache=False).use_cache
        False
    """
    # This module used to name the Cambridge host, opsin.ch.cam.ac.uk,
    # which answers every request with a 301 to here --- so asking here
    # directly saves a redirect per name. Verified live on 2026-09-19.
    self.base_url = "https://www.ebi.ac.uk/opsin/ws/"
    self.use_cache = use_cache
    self.logger = logging.getLogger(__name__)

    # One shared transport. Before this the module had no pacing, no retry
    # and no timeout handling: a momentary 503 reached the caller as a raw
    # KeyError out of the status-code table.
    self._http = HTTPClient(
        min_interval=OPSIN_MIN_INTERVAL,
        classify=opsin_classify,
        error_cls=OPSINError,
        not_found_cls=OPSINNotFoundError,
        timeout_cls=OPSINTimeoutError,
        pace_host=self.base_url,
        logger=self.logger,
    )
__cache_key__()

Identify this client for cache-key purposes.

Without this the key is the class name alone, which is stable across processes --- what stable_key_part is for --- but carries no way to retire an entry whose content is now known to be wrong. The schema version is that way.

Returns:

Type Description
tuple

Tuple of the class path, the configured base URL and CACHE_SCHEMA_VERSION.

Examples:

>>> OPSIN().__cache_key__()
('provesid.opsin.OPSIN', 'https://www.ebi.ac.uk/opsin/ws/', 2)
Source code in src/provesid/opsin.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
def __cache_key__(self) -> tuple:
    """
    Identify this client for cache-key purposes.

    Without this the key is the class name alone, which is stable across
    processes --- what [`stable_key_part`][provesid.cache.stable_key_part]
    is for --- but carries no way to retire an entry whose content is now
    known to be wrong. The schema version is that way.

    Returns:
        Tuple of the class path, the configured base URL and
        [`CACHE_SCHEMA_VERSION`][provesid.opsin.OPSIN.CACHE_SCHEMA_VERSION].

    Examples:
        >>> OPSIN().__cache_key__()
        ('provesid.opsin.OPSIN', 'https://www.ebi.ac.uk/opsin/ws/', 2)
    """
    return ("provesid.opsin.OPSIN", self.base_url,
            self.CACHE_SCHEMA_VERSION)
clear_cache()

Delete every cached OPSIN answer, in memory and on disk.

The same as provesid.clear_cache(service='opsin').

Examples:

>>> opsin = OPSIN()
>>> opsin.clear_cache()
>>> opsin.get_cache_info()['file_count']
0
Source code in src/provesid/opsin.py
258
259
260
261
262
263
264
265
266
267
268
269
270
def clear_cache(self):
    """
    Delete every cached OPSIN answer, in memory and on disk.

    The same as ``provesid.clear_cache(service='opsin')``.

    Examples:
        >>> opsin = OPSIN()
        >>> opsin.clear_cache()
        >>> opsin.get_cache_info()['file_count']
        0
    """
    clear_cache(service='opsin')
get_cache_info()

Size and location of the OPSIN cache.

Returns:

Type Description

The statistics provesid.cache.get_cache_info reports for service='opsin'.

Examples:

>>> OPSIN().get_cache_info()['cache_directory'].endswith('opsin')
True
Source code in src/provesid/opsin.py
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def get_cache_info(self):
    """
    Size and location of the OPSIN cache.

    Returns:
        The statistics
        [`provesid.cache.get_cache_info`][provesid.cache.get_cache_info]
        reports for
            ``service='opsin'``.

    Examples:
        >>> OPSIN().get_cache_info()['cache_directory'].endswith('opsin')
        True
    """
    return get_cache_info(service='opsin')
get_id(iupac_name, timeout=30)

Return the structure OPSIN parses out of an IUPAC name.

Adapted from the IUPAC WorldFAIR book: https://iupac.github.io/WFChemCookbook/tools/opsin_api_jn.html

Parameters:

Name Type Description Default
iupac_name str

The systematic name to parse.

required
timeout

Request timeout in seconds.

30

Returns:

Type Description

A dict in the shape of _empty_res. status is OPSIN's own, "SUCCESS" or "FAILURE", and on a failure message carries OPSIN's explanation of which part of the name it could not read. On a transport failure status is "FAILURE" and message describes the failure; nothing is raised, and nothing is cached. A blank name is a "FAILURE" without a request: sent, it would leave the URL ending in ws/.json, and OPSIN would answer about the name "ws".

Examples:

>>> OPSIN().get_id("ethanol")["smiles"]
'C(C)O'
>>> OPSIN().get_id("notachemical")["status"]
'FAILURE'
>>> OPSIN().get_id("  ")["message"]
'empty name'
Source code in src/provesid/opsin.py
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
@cached(service='opsin', skip_if=_not_resolved)
def get_id(self, iupac_name: str, timeout=30):
    """
    Return the structure OPSIN parses out of an IUPAC name.

    Adapted from the IUPAC WorldFAIR book:
    https://iupac.github.io/WFChemCookbook/tools/opsin_api_jn.html

    Args:
        iupac_name: The systematic name to parse.
        timeout: Request timeout in seconds.

    Returns:
        A dict in the shape of `_empty_res`. ``status`` is OPSIN's
        own, ``"SUCCESS"`` or ``"FAILURE"``, and on a failure ``message``
        carries OPSIN's explanation of which part of the name it could not
        read. On a transport failure ``status`` is ``"FAILURE"`` and
        ``message`` describes the failure; nothing is raised, and nothing
        is cached. A blank name is a ``"FAILURE"`` without a request:
        sent, it would leave the URL ending in ``ws/.json``, and OPSIN
        would answer about the name "ws".

    Examples:
        >>> OPSIN().get_id("ethanol")["smiles"]        # doctest: +SKIP
        'C(C)O'
        >>> OPSIN().get_id("notachemical")["status"]   # doctest: +SKIP
        'FAILURE'
        >>> OPSIN().get_id("  ")["message"]
        'empty name'
    """
    res = self._empty_res()
    res["iupac_name"] = iupac_name
    if not iupac_name.strip():
        res["status"] = "FAILURE"
        res["message"] = "empty name"
        return res

    apiurl = self.base_url + iupac_name + '.json'

    try:
        jsondata = self._http.get_json(apiurl, timeout=timeout)
    except OPSINError as e:
        # A parse failure is not an error --- opsin_classify reads that out
        # of the 404 body. Reaching here means the service itself failed.
        res["status"] = "FAILURE"
        res["message"] = str(e)
        self.logger.warning(f"OPSIN request failed for {iupac_name}: {e}")
        return res

    res["status"] = jsondata.get("status", "FAILURE")
    res["message"] = jsondata.get("message", "")
    if res["status"] != "SUCCESS":
        return res

    res["smiles"] = jsondata["smiles"]
    res["stdinchi"] = jsondata["stdinchi"]
    res["stdinchikey"] = jsondata["stdinchikey"]
    res["inchi"] = jsondata["inchi"]
    return res
get_id_from_list(iupac_names, timeout=30, pause_time=0.5)

Parse a list of IUPAC names, one request each.

Parameters:

Name Type Description Default
iupac_names list

The systematic names to parse.

required
timeout

Per-request timeout in seconds.

30
pause_time

Seconds to sleep after each name, on top of the pacing the transport already applies.

0.5

Returns:

Type Description

One dict per input name, in order, each in the shape get_id returns. A name OPSIN could not parse is a "FAILURE" entry carrying OPSIN's explanation in message, not a missing row.

Examples:

>>> records = OPSIN().get_id_from_list(["ethanol", "benzene"])
>>> [(r["iupac_name"], r["status"]) for r in records]
[('ethanol', 'SUCCESS'), ('benzene', 'SUCCESS')]
Source code in src/provesid/opsin.py
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
@cached(service='opsin', skip_if=_any_not_resolved)
def get_id_from_list(self, iupac_names: list, timeout=30, pause_time=0.5):
    """
    Parse a list of IUPAC names, one request each.

    Args:
        iupac_names: The systematic names to parse.
        timeout: Per-request timeout in seconds.
        pause_time: Seconds to sleep after each name, on top of the pacing
            the transport already applies.

    Returns:
        One dict per input name, in order, each in the shape
        [`get_id`][provesid.opsin.OPSIN.get_id] returns. A name OPSIN could
        not parse is a ``"FAILURE"`` entry carrying OPSIN's explanation in
        ``message``, not a missing row.

    Examples:
        >>> records = OPSIN().get_id_from_list(["ethanol", "benzene"])  # doctest: +SKIP
        >>> [(r["iupac_name"], r["status"]) for r in records]           # doctest: +SKIP
        [('ethanol', 'SUCCESS'), ('benzene', 'SUCCESS')]
    """
    results = []
    for iupac_name in iupac_names:
        res = self.get_id(iupac_name, timeout)
        if res["status"] != "SUCCESS":
            self.logger.warning(
                f"Failed to get ID for {iupac_name}: {res['message']}"
            )
        results.append(res)
        time.sleep(pause_time)
    return results

PYOPSIN

OPSIN run locally through py2opsin: offline, and faster for many names.

py2opsin (https://github.com/JacksonBurns/py2opsin, pip install py2opsin) bundles OPSIN's jar and calls it through Java, so a Java runtime must be installed. Each method starts the JVM once, about half a second, whether it is given one name or a list; pass a list to parse many.

Every method accepts a name or a list of names and returns a string or a list of strings to match. A name OPSIN cannot parse gives "" and a RuntimeWarning from py2opsin; nothing is raised.

Examples:

>>> opsin = PYOPSIN()
>>> opsin.get_smiles("ethanol")
'C(C)O'
>>> opsin.get_smiles(["ethanol", "benzene"])
['C(C)O', 'C1=CC=CC=C1']
Source code in src/provesid/opsin.py
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
class PYOPSIN:
    """
    OPSIN run locally through ``py2opsin``: offline, and faster for many names.

    ``py2opsin`` (https://github.com/JacksonBurns/py2opsin, ``pip install
    py2opsin``) bundles OPSIN's jar and calls it through Java, so a Java
    runtime must be installed. Each method starts the JVM once, about half a
    second, whether it is given one name or a list; pass a list to parse many.

    Every method accepts a name or a list of names and returns a string or a
    list of strings to match. A name OPSIN cannot parse gives ``""`` and a
    ``RuntimeWarning`` from ``py2opsin``; nothing is raised.

    Examples:
        >>> opsin = PYOPSIN()
        >>> opsin.get_smiles("ethanol")                            # doctest: +SKIP
        'C(C)O'
        >>> opsin.get_smiles(["ethanol", "benzene"])               # doctest: +SKIP
        ['C(C)O', 'C1=CC=CC=C1']
    """
    def __init__(self, jar_fpath = "default"):
        """
        Initialize the local OPSIN parser.

        Args:
            jar_fpath: Path to an OPSIN jar, or ``"default"`` for the one
                ``py2opsin`` ships.

        Examples:
            >>> PYOPSIN().jar_fpath
            'default'
        """
        self.jar_fpath = jar_fpath

    def get_smiles(self, iupac_name: str):
        """
        Parse a name to SMILES, as OPSIN writes it (not canonicalised).

        Args:
            iupac_name: A name, or a list of names.

        Returns:
            The SMILES, or a list of them; ``""`` for a name OPSIN cannot parse.

        Examples:
            >>> PYOPSIN().get_smiles("ethanol")                    # doctest: +SKIP
            'C(C)O'
            >>> PYOPSIN().get_smiles("notachemical12345")          # doctest: +SKIP
            ''
        """
        smiles = py2opsin(iupac_name, output_format="SMILES", jar_fpath=self.jar_fpath)
        return smiles

    def get_extended_smiles(self, iupac_name: str):
        """
        Parse a name to ChemAxon extended SMILES, which keeps atom labels.

        Args:
            iupac_name: A name, or a list of names.

        Returns:
            The extended SMILES, or a list of them; ``""`` on failure.

        Examples:
            >>> PYOPSIN().get_extended_smiles("ethanol")           # doctest: +SKIP
            'C(C)O |$_AV:1;2;O$|'
        """
        ext_smiles = py2opsin(iupac_name, output_format="ExtendedSMILES", jar_fpath=self.jar_fpath)
        return ext_smiles

    def get_inchi(self, iupac_name: str):
        """
        Parse a name to a non-standard InChI (``InChI=1/...``).

        Args:
            iupac_name: A name, or a list of names.

        Returns:
            The InChI, or a list of them; ``""`` on failure.

        Examples:
            >>> PYOPSIN().get_inchi("ethanol")                     # doctest: +SKIP
            'InChI=1/C2H6O/c1-2-3/h3H,2H2,1H3'
        """
        inchi = py2opsin(iupac_name, output_format="InChI", jar_fpath=self.jar_fpath)
        return inchi

    def get_std_inchi(self, iupac_name: str):
        """
        Parse a name to a standard InChI (``InChI=1S/...``).

        Args:
            iupac_name: A name, or a list of names.

        Returns:
            The standard InChI, or a list of them; ``""`` on failure.

        Examples:
            >>> PYOPSIN().get_std_inchi("ethanol")                 # doctest: +SKIP
            'InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3'
        """
        std_inchi = py2opsin(iupac_name, output_format="StdInChI", jar_fpath=self.jar_fpath)
        return std_inchi

    def get_std_inchikey(self, iupac_name: str):
        """
        Parse a name to a standard InChIKey.

        Args:
            iupac_name: A name, or a list of names.

        Returns:
            The InChIKey, or a list of them; ``""`` on failure.

        Examples:
            >>> PYOPSIN().get_std_inchikey("ethanol")              # doctest: +SKIP
            'LFQSCWFLJHTTHZ-UHFFFAOYSA-N'
        """
        std_inchikey = py2opsin(iupac_name, output_format="StdInChIKey", jar_fpath=self.jar_fpath)
        return std_inchikey

    def get_CML(self, iupac_name: str):
        """
        Parse a name to a Chemical Markup Language document.

        Args:
            iupac_name: A name, or a list of names.

        Returns:
            The CML as an XML string. For a list, the lines of one document
            holding a ``<molecule>`` per name; ``get_id_from_list`` cuts it
            into one document per name. A name OPSIN cannot parse still gets
            a molecule, holding only its ``<name>``, not ``""``.

        Examples:
            >>> PYOPSIN().get_CML("ethanol")[:38]                  # doctest: +SKIP
            "<?xml version='1.0' encoding='UTF-8'?>"
        """
        cml = py2opsin(iupac_name, output_format="CML", jar_fpath=self.jar_fpath)
        return cml

    def get_id(self, iupac_name: str):
        """
        Parse one name to every representation OPSIN writes.

        Six parser runs, one per representation. For more than one name use
        [`get_id_from_list`][provesid.opsin.PYOPSIN.get_id_from_list], which
        costs the same six runs for the whole list.

        Args:
            iupac_name: The systematic name.

        Returns:
            (dict): ``iupac_name``, ``status`` (``"SUCCESS"`` when a SMILES came
            back, else ``"FAILURE"``), ``message`` (always empty here),
            ``smiles``, ``extended_smiles``, ``inchi``, ``stdinchi``,
            ``stdinchikey`` and ``cml``.

        Examples:
            >>> record = PYOPSIN().get_id("ethanol")               # doctest: +SKIP
            >>> record["status"], record["smiles"], record["stdinchikey"]  # doctest: +SKIP
            ('SUCCESS', 'C(C)O', 'LFQSCWFLJHTTHZ-UHFFFAOYSA-N')
        """
        res = self._empty_res()
        res["iupac_name"] = iupac_name
        res["smiles"] = self.get_smiles(iupac_name)
        res["extended_smiles"] = self.get_extended_smiles(iupac_name)
        res["inchi"] = self.get_inchi(iupac_name)
        res["stdinchi"] = self.get_std_inchi(iupac_name)
        res["stdinchikey"] = self.get_std_inchikey(iupac_name)
        res["cml"] = self.get_CML(iupac_name)
        if res["smiles"] == "":
            res["status"] = "FAILURE"
        else:
            res["status"] = "SUCCESS"
        return res

    def get_id_from_list(self, iupac_names: list):
        """
        Parse many names to every representation, six parser runs in all.

        py2opsin takes the whole list per run, so this does not loop over
        [`get_id`][provesid.opsin.PYOPSIN.get_id].

        Args:
            iupac_names: The systematic names.

        Returns:
            (list): One [`get_id`][provesid.opsin.PYOPSIN.get_id] dict per name,
                in order, each with its own ``status`` and its own ``cml``
                document, the one ``get_id`` gives for that name alone. An
                empty list gives ``[]`` without starting Java.

        Examples:
            >>> records = PYOPSIN().get_id_from_list(["ethanol", "notachemical12345"])  # doctest: +SKIP
            >>> [(r["iupac_name"], r["smiles"], r["status"]) for r in records]           # doctest: +SKIP
            [('ethanol', 'C(C)O', 'SUCCESS'), ('notachemical12345', '', 'FAILURE')]
        """
        if not iupac_names:
            return []
        res = self.get_id(iupac_names)
        cml_per_name = self._split_cml(res["cml"], len(iupac_names))
        # convert to list of dicts
        results = []
        for i, name in enumerate(iupac_names):
            single_res = self._empty_res()
            single_res["iupac_name"] = name
            single_res["smiles"] = res["smiles"][i]
            single_res["extended_smiles"] = res["extended_smiles"][i]
            single_res["inchi"] = res["inchi"][i]
            single_res["stdinchi"] = res["stdinchi"][i]
            single_res["stdinchikey"] = res["stdinchikey"][i]
            single_res["cml"] = cml_per_name[i]
            # get_id gives one status for the whole list; indexing it gave
            # each name a letter of "SUCCESS".
            single_res["status"] = "SUCCESS" if single_res["smiles"] else "FAILURE"
            results.append(single_res)
        return results

    @staticmethod
    def _split_cml(cml_lines: list, n_names: int) -> list:
        """
        Cut the CML document OPSIN writes for a list into one per name.

        For a list, the other formats give one line per name. CML gives a
        single document, which py2opsin splits at its newlines, so indexing
        it gave each name one line of XML. The document holds one top-level
        ``<molecule id="mN">`` per name, in input order. A name OPSIN cannot
        parse keeps its molecule, holding only its ``<name>``. A salt is one
        molecule, too.

        Each piece is the XML declaration and ``<cml>`` root, the name's own
        molecule renumbered ``m1``, and ``</cml>``, joined without newlines.
        That is the string ``get_CML(name)`` returns for the name alone.

        Args:
            cml_lines: What ``get_CML`` returned for the list.
            n_names: How many names were parsed.

        Returns:
            (list): One CML string per name, in order. If the document does
                not hold ``n_names`` molecules, every string is ``""`` and a
                warning is logged, because the pieces could not be matched
                to the names.
        """
        molecule_starts = [
            i for i, line in enumerate(cml_lines) if line.startswith("  <molecule ")
        ]
        if len(molecule_starts) != n_names:
            logging.getLogger(__name__).warning(
                "OPSIN's CML holds %d molecules for %d names; cml left empty.",
                len(molecule_starts), n_names,
            )
            return [""] * n_names

        root = "".join(cml_lines[:molecule_starts[0]])
        molecule_ends = molecule_starts[1:] + [cml_lines.index("</cml>")]
        pieces = []
        for start, end in zip(molecule_starts, molecule_ends):
            first_line = re.sub(r'id="m\d+"', 'id="m1"', cml_lines[start], count=1)
            molecule = first_line + "".join(cml_lines[start + 1:end])
            pieces.append(root + molecule + "</cml>")
        return pieces

    @staticmethod
    def _empty_res():
        """
        “SMILES”, “ExtendedSMILES”,
    “CML”, “InChI”, “StdInChI”, or “StdInChIKey”. Defaults to “SMILES”.
        create an empty response dictionary of the following format:
        {
            "status": "SUCCESS",
            "message": "",
            "inchi": "InChI=1/C2H2Cl4/c3-1(4)2(5)6/h1-2H",
            "stdinchi": "InChI=1S/C2H2Cl4/c3-1(4)2(5)6/h1-2H",
            "stdinchikey": "QPFMBZIOSGYJDE-UHFFFAOYSA-N",
            "smiles": "ClC(C(Cl)Cl)Cl",
            "extended_smiles": "",
            "cml": ""
        }
        """
        return {
            "status": "",
            "message": "",
            "inchi": "",
            "stdinchi": "",
            "stdinchikey": "",
            "smiles": "",
            "extended_smiles": "",
            "cml": ""
        }
Methods:
__init__(jar_fpath='default')

Initialize the local OPSIN parser.

Parameters:

Name Type Description Default
jar_fpath

Path to an OPSIN jar, or "default" for the one py2opsin ships.

'default'

Examples:

>>> PYOPSIN().jar_fpath
'default'
Source code in src/provesid/opsin.py
424
425
426
427
428
429
430
431
432
433
434
435
436
def __init__(self, jar_fpath = "default"):
    """
    Initialize the local OPSIN parser.

    Args:
        jar_fpath: Path to an OPSIN jar, or ``"default"`` for the one
            ``py2opsin`` ships.

    Examples:
        >>> PYOPSIN().jar_fpath
        'default'
    """
    self.jar_fpath = jar_fpath
get_smiles(iupac_name)

Parse a name to SMILES, as OPSIN writes it (not canonicalised).

Parameters:

Name Type Description Default
iupac_name str

A name, or a list of names.

required

Returns:

Type Description

The SMILES, or a list of them; "" for a name OPSIN cannot parse.

Examples:

>>> PYOPSIN().get_smiles("ethanol")
'C(C)O'
>>> PYOPSIN().get_smiles("notachemical12345")
''
Source code in src/provesid/opsin.py
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
def get_smiles(self, iupac_name: str):
    """
    Parse a name to SMILES, as OPSIN writes it (not canonicalised).

    Args:
        iupac_name: A name, or a list of names.

    Returns:
        The SMILES, or a list of them; ``""`` for a name OPSIN cannot parse.

    Examples:
        >>> PYOPSIN().get_smiles("ethanol")                    # doctest: +SKIP
        'C(C)O'
        >>> PYOPSIN().get_smiles("notachemical12345")          # doctest: +SKIP
        ''
    """
    smiles = py2opsin(iupac_name, output_format="SMILES", jar_fpath=self.jar_fpath)
    return smiles
get_extended_smiles(iupac_name)

Parse a name to ChemAxon extended SMILES, which keeps atom labels.

Parameters:

Name Type Description Default
iupac_name str

A name, or a list of names.

required

Returns:

Type Description

The extended SMILES, or a list of them; "" on failure.

Examples:

>>> PYOPSIN().get_extended_smiles("ethanol")
'C(C)O |$_AV:1;2;O$|'
Source code in src/provesid/opsin.py
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
def get_extended_smiles(self, iupac_name: str):
    """
    Parse a name to ChemAxon extended SMILES, which keeps atom labels.

    Args:
        iupac_name: A name, or a list of names.

    Returns:
        The extended SMILES, or a list of them; ``""`` on failure.

    Examples:
        >>> PYOPSIN().get_extended_smiles("ethanol")           # doctest: +SKIP
        'C(C)O |$_AV:1;2;O$|'
    """
    ext_smiles = py2opsin(iupac_name, output_format="ExtendedSMILES", jar_fpath=self.jar_fpath)
    return ext_smiles
get_inchi(iupac_name)

Parse a name to a non-standard InChI (InChI=1/...).

Parameters:

Name Type Description Default
iupac_name str

A name, or a list of names.

required

Returns:

Type Description

The InChI, or a list of them; "" on failure.

Examples:

>>> PYOPSIN().get_inchi("ethanol")
'InChI=1/C2H6O/c1-2-3/h3H,2H2,1H3'
Source code in src/provesid/opsin.py
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
def get_inchi(self, iupac_name: str):
    """
    Parse a name to a non-standard InChI (``InChI=1/...``).

    Args:
        iupac_name: A name, or a list of names.

    Returns:
        The InChI, or a list of them; ``""`` on failure.

    Examples:
        >>> PYOPSIN().get_inchi("ethanol")                     # doctest: +SKIP
        'InChI=1/C2H6O/c1-2-3/h3H,2H2,1H3'
    """
    inchi = py2opsin(iupac_name, output_format="InChI", jar_fpath=self.jar_fpath)
    return inchi
get_std_inchi(iupac_name)

Parse a name to a standard InChI (InChI=1S/...).

Parameters:

Name Type Description Default
iupac_name str

A name, or a list of names.

required

Returns:

Type Description

The standard InChI, or a list of them; "" on failure.

Examples:

>>> PYOPSIN().get_std_inchi("ethanol")
'InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3'
Source code in src/provesid/opsin.py
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
def get_std_inchi(self, iupac_name: str):
    """
    Parse a name to a standard InChI (``InChI=1S/...``).

    Args:
        iupac_name: A name, or a list of names.

    Returns:
        The standard InChI, or a list of them; ``""`` on failure.

    Examples:
        >>> PYOPSIN().get_std_inchi("ethanol")                 # doctest: +SKIP
        'InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3'
    """
    std_inchi = py2opsin(iupac_name, output_format="StdInChI", jar_fpath=self.jar_fpath)
    return std_inchi
get_std_inchikey(iupac_name)

Parse a name to a standard InChIKey.

Parameters:

Name Type Description Default
iupac_name str

A name, or a list of names.

required

Returns:

Type Description

The InChIKey, or a list of them; "" on failure.

Examples:

>>> PYOPSIN().get_std_inchikey("ethanol")
'LFQSCWFLJHTTHZ-UHFFFAOYSA-N'
Source code in src/provesid/opsin.py
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
def get_std_inchikey(self, iupac_name: str):
    """
    Parse a name to a standard InChIKey.

    Args:
        iupac_name: A name, or a list of names.

    Returns:
        The InChIKey, or a list of them; ``""`` on failure.

    Examples:
        >>> PYOPSIN().get_std_inchikey("ethanol")              # doctest: +SKIP
        'LFQSCWFLJHTTHZ-UHFFFAOYSA-N'
    """
    std_inchikey = py2opsin(iupac_name, output_format="StdInChIKey", jar_fpath=self.jar_fpath)
    return std_inchikey
get_CML(iupac_name)

Parse a name to a Chemical Markup Language document.

Parameters:

Name Type Description Default
iupac_name str

A name, or a list of names.

required

Returns:

Type Description

The CML as an XML string. For a list, the lines of one document holding a <molecule> per name; get_id_from_list cuts it into one document per name. A name OPSIN cannot parse still gets a molecule, holding only its <name>, not "".

Examples:

>>> PYOPSIN().get_CML("ethanol")[:38]
"<?xml version='1.0' encoding='UTF-8'?>"
Source code in src/provesid/opsin.py
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
def get_CML(self, iupac_name: str):
    """
    Parse a name to a Chemical Markup Language document.

    Args:
        iupac_name: A name, or a list of names.

    Returns:
        The CML as an XML string. For a list, the lines of one document
        holding a ``<molecule>`` per name; ``get_id_from_list`` cuts it
        into one document per name. A name OPSIN cannot parse still gets
        a molecule, holding only its ``<name>``, not ``""``.

    Examples:
        >>> PYOPSIN().get_CML("ethanol")[:38]                  # doctest: +SKIP
        "<?xml version='1.0' encoding='UTF-8'?>"
    """
    cml = py2opsin(iupac_name, output_format="CML", jar_fpath=self.jar_fpath)
    return cml
get_id(iupac_name)

Parse one name to every representation OPSIN writes.

Six parser runs, one per representation. For more than one name use get_id_from_list, which costs the same six runs for the whole list.

Parameters:

Name Type Description Default
iupac_name str

The systematic name.

required

Returns:

Type Description
dict

iupac_name, status ("SUCCESS" when a SMILES came back, else "FAILURE"), message (always empty here), smiles, extended_smiles, inchi, stdinchi, stdinchikey and cml.

Examples:

>>> record = PYOPSIN().get_id("ethanol")
>>> record["status"], record["smiles"], record["stdinchikey"]
('SUCCESS', 'C(C)O', 'LFQSCWFLJHTTHZ-UHFFFAOYSA-N')
Source code in src/provesid/opsin.py
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
def get_id(self, iupac_name: str):
    """
    Parse one name to every representation OPSIN writes.

    Six parser runs, one per representation. For more than one name use
    [`get_id_from_list`][provesid.opsin.PYOPSIN.get_id_from_list], which
    costs the same six runs for the whole list.

    Args:
        iupac_name: The systematic name.

    Returns:
        (dict): ``iupac_name``, ``status`` (``"SUCCESS"`` when a SMILES came
        back, else ``"FAILURE"``), ``message`` (always empty here),
        ``smiles``, ``extended_smiles``, ``inchi``, ``stdinchi``,
        ``stdinchikey`` and ``cml``.

    Examples:
        >>> record = PYOPSIN().get_id("ethanol")               # doctest: +SKIP
        >>> record["status"], record["smiles"], record["stdinchikey"]  # doctest: +SKIP
        ('SUCCESS', 'C(C)O', 'LFQSCWFLJHTTHZ-UHFFFAOYSA-N')
    """
    res = self._empty_res()
    res["iupac_name"] = iupac_name
    res["smiles"] = self.get_smiles(iupac_name)
    res["extended_smiles"] = self.get_extended_smiles(iupac_name)
    res["inchi"] = self.get_inchi(iupac_name)
    res["stdinchi"] = self.get_std_inchi(iupac_name)
    res["stdinchikey"] = self.get_std_inchikey(iupac_name)
    res["cml"] = self.get_CML(iupac_name)
    if res["smiles"] == "":
        res["status"] = "FAILURE"
    else:
        res["status"] = "SUCCESS"
    return res
get_id_from_list(iupac_names)

Parse many names to every representation, six parser runs in all.

py2opsin takes the whole list per run, so this does not loop over get_id.

Parameters:

Name Type Description Default
iupac_names list

The systematic names.

required

Returns:

Type Description
list

One get_id dict per name, in order, each with its own status and its own cml document, the one get_id gives for that name alone. An empty list gives [] without starting Java.

Examples:

>>> records = PYOPSIN().get_id_from_list(["ethanol", "notachemical12345"])
>>> [(r["iupac_name"], r["smiles"], r["status"]) for r in records]
[('ethanol', 'C(C)O', 'SUCCESS'), ('notachemical12345', '', 'FAILURE')]
Source code in src/provesid/opsin.py
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
def get_id_from_list(self, iupac_names: list):
    """
    Parse many names to every representation, six parser runs in all.

    py2opsin takes the whole list per run, so this does not loop over
    [`get_id`][provesid.opsin.PYOPSIN.get_id].

    Args:
        iupac_names: The systematic names.

    Returns:
        (list): One [`get_id`][provesid.opsin.PYOPSIN.get_id] dict per name,
            in order, each with its own ``status`` and its own ``cml``
            document, the one ``get_id`` gives for that name alone. An
            empty list gives ``[]`` without starting Java.

    Examples:
        >>> records = PYOPSIN().get_id_from_list(["ethanol", "notachemical12345"])  # doctest: +SKIP
        >>> [(r["iupac_name"], r["smiles"], r["status"]) for r in records]           # doctest: +SKIP
        [('ethanol', 'C(C)O', 'SUCCESS'), ('notachemical12345', '', 'FAILURE')]
    """
    if not iupac_names:
        return []
    res = self.get_id(iupac_names)
    cml_per_name = self._split_cml(res["cml"], len(iupac_names))
    # convert to list of dicts
    results = []
    for i, name in enumerate(iupac_names):
        single_res = self._empty_res()
        single_res["iupac_name"] = name
        single_res["smiles"] = res["smiles"][i]
        single_res["extended_smiles"] = res["extended_smiles"][i]
        single_res["inchi"] = res["inchi"][i]
        single_res["stdinchi"] = res["stdinchi"][i]
        single_res["stdinchikey"] = res["stdinchikey"][i]
        single_res["cml"] = cml_per_name[i]
        # get_id gives one status for the whole list; indexing it gave
        # each name a letter of "SUCCESS".
        single_res["status"] = "SUCCESS" if single_res["smiles"] else "FAILURE"
        results.append(single_res)
    return results

Functions:

opsin_classify(response)

Decide what an OPSIN response means, treating its 404 as an answer.

OPSIN answers a name it cannot parse with HTTP 404 and a complete JSON body: {"status": "FAILURE", "message": "<name> was uninterpretable due to the following section of the name: ..."}. The 404 is an envelope, not the answer --- the answer is in the body, and it is the only place the reason exists. Verified live on 2026-09-19 against notachemical12345.

Treating that as ABSENT would raise before the body was read, which is what the module did by hand before: it mapped the status to the string "FAILURE" and returned, so message was empty for every failure the module ever reported --- including in the WARNING that get_id_from_list logs, which has therefore always said "Failed to get ID for x: ".

Parameters:

Name Type Description Default
response Response

The response to classify.

required

Returns:

Type Description
Outcome

OK for a 404, whose body is the answer; otherwise whatever default_classify says.

Examples:

>>> class R: status_code = 404
>>> opsin_classify(R()).name
'OK'
>>> class R: status_code = 503
>>> opsin_classify(R()).name
'RETRY'
Source code in src/provesid/opsin.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def opsin_classify(response: requests.Response) -> Outcome:
    """
    Decide what an OPSIN response means, treating its 404 as an answer.

    OPSIN answers a name it cannot parse with HTTP 404 and a complete JSON
    body: ``{"status": "FAILURE", "message": "<name> was uninterpretable due
    to the following section of the name: ..."}``. The 404 is an envelope, not
    the answer --- the answer is in the body, and it is the only place the
    *reason* exists. Verified live on 2026-09-19 against
    ``notachemical12345``.

    Treating that as [`ABSENT`][provesid.http.Outcome] would raise before
    the body was read, which is what the module did by hand before: it mapped
    the status to the string ``"FAILURE"`` and returned, so ``message`` was
    empty for every failure the module ever reported --- including in the
    WARNING that ``get_id_from_list`` logs, which has therefore always said
    "Failed to get ID for x: ".

    Args:
        response: The response to classify.

    Returns:
        [`OK`][provesid.http.Outcome] for a 404, whose body is the answer;
        otherwise whatever [`default_classify`][provesid.http.default_classify] says.

    Examples:
        >>> class R: status_code = 404
        >>> opsin_classify(R()).name
        'OK'
        >>> class R: status_code = 503
        >>> opsin_classify(R()).name
        'RETRY'
    """
    if response.status_code == 404:
        return Outcome.OK
    return default_classify(response)