Metadata-Version: 2.4
Name: sendoff
Version: 1.0.0
Classifier: Intended Audience :: Science/Research
Classifier: Topic :: Scientific/Engineering :: Chemistry
Classifier: Typing :: Typed
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Programming Language :: Rust
Requires-Dist: sendoff[test] ; extra == 'dev'
Requires-Dist: maturin==1.15.0 ; extra == 'dev'
Requires-Dist: tox>=4.24,<5 ; extra == 'dev'
Requires-Dist: ruff>=0.17,<1 ; extra == 'dev'
Requires-Dist: pre-commit>=3.5.0,<5 ; extra == 'dev'
Requires-Dist: tbump>=6.11.0,<7 ; extra == 'dev'
Requires-Dist: sendoff[test-core] ; extra == 'test'
Requires-Dist: rdkit==2022.9.5 ; python_full_version == '3.11.*' and platform_machine != 'ARM64' and extra == 'test'
Requires-Dist: numpy==1.23.5 ; python_full_version == '3.11.*' and platform_machine != 'ARM64' and extra == 'test'
Requires-Dist: rdkit==2026.9.1 ; (python_full_version >= '3.12' and platform_machine != 'ARM64' and platform_machine != 'x86_64' and extra == 'test') or (python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform != 'darwin' and extra == 'test')
Requires-Dist: rdkit==2025.9.3 ; python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'test'
Requires-Dist: numpy>=1.26.4,<3 ; python_full_version >= '3.12' and platform_machine != 'ARM64' and extra == 'test'
Requires-Dist: pytest>=7.4.3,<10 ; extra == 'test-core'
Requires-Dist: xdoctest>=1.1.2,<2 ; extra == 'test-core'
Requires-Dist: pytest-cov>=4.1.0,<8 ; extra == 'test-core'
Requires-Dist: pytest-mock>=3.12.0,<4 ; extra == 'test-core'
Requires-Dist: coverage>=7.3.2,<8 ; extra == 'test-core'
Requires-Dist: hypothesis>=6.100,<7 ; extra == 'test-core'
Requires-Dist: mypy>=2.4,<3 ; extra == 'test-core'
Provides-Extra: dev
Provides-Extra: test
Provides-Extra: test-core
License-File: LICENSE
Summary: The minimal SDF metadata parser
Author-email: Yakov Pechersky <ypechersky@treeline.bio>
License-Expression: MIT
Requires-Python: >=3.11
Description-Content-Type: text/x-rst; charset=UTF-8
Project-URL: Changelog, https://github.com/pechersky/sendoff/releases
Project-URL: Issues, https://github.com/pechersky/sendoff/issues
Project-URL: Repository, https://github.com/pechersky/sendoff

sendoff
=======

.. image:: https://results.pre-commit.ci/badge/github/pechersky/sendoff/main.svg
   :target: https://results.pre-commit.ci/latest/github/pechersky/sendoff/main
   :alt: pre-commit.ci status

.. image:: https://github.com/pechersky/sendoff/actions/workflows/tox.yml/badge.svg
   :target: https://github.com/pechersky/sendoff/actions/workflows/tox.yml
   :alt: Tests

The minimal SDF metadata parser.

Often, SDFs have lots of useful metadata on them in the title and record fields/values.
However, reading a molecule (via rdkit, OpenEye toolkits, etc) can be slow because those
libraries also construct the molecules. Modifying the metadata, or filtering/sorting based
on the metadata also can induce non-idempotent differences in the file based on
opinionated approaches in chemical libraries.

This library strives to be able to handle SDF files even with malformed chemistry or
metadata. Since much debugging of our files and data deals with such files, having access
to simple fast tools to interrogate the files while not modifying the file is crucial.

This package also tries to document the "canonical" ways metadata is handled by the larger
packages. To wit, there are tests to monitor how, for example, rdkit deals with molecules
that have multiline record values, or a "$$$$" molecule title.

In time per operation, sendoff is 31–79× faster than RDKit, 7.1–30× faster
than OEChem, and 8.8–59× faster than the pure-Python sendoff 0.1.6 (geomeans
by dataset; see the `benchmarks
<https://github.com/pechersky/sendoff/blob/main/benchmark-results.md>`_).


Usage
-----

.. code-block:: python

   from collections import Counter
   from rdkit import Chem
   from sendoff import SDBlock, parse_sdf, sdf_writer, write_sdf

   for block in parse_sdf("compounds.sdf"):
       block.title
       block["ID"]
       block.get("ID")
       block.to_dict()
       block.records()
       block.iter_records()
       "ID" in block, len(block), list(block)
       "V3000" in block.mdl
       mol = Chem.MolFromMolBlock(block.molblock)

   Counter(name for block in parse_sdf("compounds.sdf") for name in block)

   def tag(block):
       block["Source"] = "vendor"
       block.remove_record("Internal note")
       return block

   write_sdf("out.sdf", (tag(block) for block in parse_sdf("in.sdf")))

   with sdf_writer("active.sdf") as active, sdf_writer("rest.sdf") as rest:
       for block in parse_sdf("in.sdf"):
           (active if block.get("Active") == "1" else rest).write(block)

   block = SDBlock(Chem.MolToMolBlock(mol), {"ID": "X-1"})
   str(block)
   SDBlock.from_sdf(sdf_text)

``parse_sdf`` memory-maps local files, so write to a new path: truncating a
file while it is parsed crashes the process. ``parse_sdf(path, borrow=True)``
also skips copying each block: blocks read the file in place until edited,
so the same holds for as long as they live.

New headers are ``> <name>``; ``header="padded"`` writes ``>  <name>``, as
RDKit does, and ``header="copy"`` follows the block's first header.

Non-UTF-8 files
---------------

str paths decode UTF-8. Open other encodings yourself, or stay in bytes:

.. code-block:: python

   with open("vendor.sdf", encoding="gbk") as f:
       names = {block["CAS"]: block["Product name"] for block in parse_sdf(f)}

   in_stock = (
       block
       for block in parse_sdf(os.fsencode("vendor.sdf"))
       if block.get(b"Availability") == b"In-stock"
   )
   write_sdf("in-stock.sdf", in_stock)

Shell pipelines
---------------

.. code-block:: bash

   python -m sendoff.cli.count my.sdf
   zcat chembl.sdf.gz | python -m sendoff.cli.count
   python -m sendoff.cli.head -n 100 my.sdf > sample.sdf
   python -m sendoff.cli.tail -n +2 my.sdf > without-first.sdf
   python -m sendoff.cli.get Supplier my.sdf | sort | uniq -c
   paste <(python -m sendoff.cli.get -t my.sdf) <(python -m sendoff.cli.get CAS my.sdf)
   python -m sendoff.cli.get ID my.sdf | sed 's/^/TL-/' |
       python -m sendoff.cli.title --from - my.sdf > titled.sdf
   python -m sendoff.cli.filter --eq Availability In-stock vendor.sdf |
       python -m sendoff.cli.set Batch --value B7 -o batch.sdf
   python -m sendoff.cli.filter --unique InChIKey my.sdf > unique.sdf

The ``count``, ``head``, ``tail``, ``get``, ``set``, ``title``, and ``filter``
commands read ``FILE`` or ``-i FILE``, else stdin, as bytes and write
``-o FILE``, else stdout; ``-o`` cannot name an input. ``get`` prints one line
per block, empty where a record is missing, and ``--from`` reads one line per
block; ``-z`` uses NUL instead. Run any with ``-h``.

In Python, read compressed files with ``parse_sdf(gzip.open(path))``, and
write a pipe with ``write_sdf(sys.stdout, blocks)``, or ``sys.stdout.buffer``
for bytes blocks.

