Repository: openforcefield/openff-toolkit

Issue:
`Molecule.from_pdb_with_smiles()` warns about the pdb having unspecified stereochemistry
**Describe the bug**

`Molecule.from_pdb_and_smiles` gives a spurious warning about the PDB file having unspecified stereochemistry. This is spurious because the whole purpose of the method is to work around this limitation of the PDB format. I'm worried that the warning will be confusing to users.

**To Reproduce**

```Python
from openff.toolkit.topology import Molecule
smiles = "C[C@H]([NH3+])C(=O)[O-]"
path = "mol.pdb"
Molecule.from_smiles(smiles).to_file(path, "pdb")
mol = Molecule.from_pdb_and_smiles(
    path, 
    smiles, 
    allow_undefined_stereo=False
)
```

This succeeds, but gives a spurious warning.

**Output**

I've added line breaks for clarity

```stderr
Warning (not error because allow_undefined_stereo=True): 
    Unable to make OFFMol from RDMol: 
        RDMol has unspecified stereochemistry. 
Undefined chiral centers are:
 - Atom C (index 1)
```

This is a very confusing warning, as the `from_pdb_and_smiles` method has the `allow_undefined_stereo` argument. I'm worried that users will see this error and think that they've done something wrong or that the toolkit has a bug and is unable to assign stereochemistry to the PDB from the SMILES, when in fact this is raised in the course of reading the PDB, before the SMILES is processed.

**Computing environment (please complete the following information):**
 - Manjaro Linux
<details><summary><code>conda list</code></summary>
<pre>
# packages in environment at /home/joshm/conda/envs/offdev:
#
# Name                    Version                   Build  Channel
_libgcc_mutex             0.1                 conda_forge    conda-forge
_openmp_mutex             4.5                       1_gnu    conda-forge
abseil-cpp                20210324.1           h9c3ff4c_0    conda-forge
alabaster                 0.7.12                     py_0    conda-forge
amberlite                 16.0                     pypi_0    pypi
ambertools                21.0             py37h13c71af_0    conda-forge
amberutils                21.0                     pypi_0    pypi
anyio                     3.2.0            py37h89c1867_0    conda-forge
argon2-cffi               20.1.0           py37h5e8e339_2    conda-forge
arpack                    3.7.0                hdefa2d7_2    conda-forge
arrow-cpp                 4.0.1           py37h6a8ff91_1_cpu    conda-forge
astunparse                1.6.3              pyhd8ed1ab_0    conda-forge
async_generator           1.10                       py_0    conda-forge
attrs                     20.3.0             pyhd3deb0d_0    conda-forge
autodoc-pydantic          1.3.0                    pypi_0    pypi
autodocsumm               0.2.4              pyhd8ed1ab_0    conda-forge
aws-c-cal                 0.5.9                h3622835_0    conda-forge
aws-c-common              0.5.11               h7f98852_0    conda-forge
aws-c-event-stream        0.2.7                h8fbaa10_8    conda-forge
aws-c-io                  0.10.1               h8007ed0_0    conda-forge
aws-checksums             0.1.11               hc0e0e8b_6    conda-forge
aws-sdk-cpp               1.8.186              h9ad65fb_2    conda-forge
babel                     2.9.1              pyh44b312d_0    conda-forge
backcall                  0.2.0              pyh9f0ad1d_0    conda-forge
backports                 1.0                        py_2    conda-forge
backports.functools_lru_cache 1.6.4              pyhd8ed1ab_0    conda-forge
beautifulsoup4            4.9.3              pyhb0f4dca_0    conda-forge
bleach                    3.3.0              pyh44b312d_0    conda-forge
blosc                     1.21.0               h9c3ff4c_0    conda-forge
boost                     1.74.0           py37h6dcda5c_3    conda-forge
boost-cpp                 1.74.0               h312852a_4    conda-forge
brotli                    1.0.9                h9c3ff4c_4    conda-forge
brotlipy                  0.7.0           py37h5e8e339_1001    conda-forge
bson                      0.5.9                      py_0    conda-forge
bzip2                     1.0.8                h7f98852_4    conda-forge
c-ares                    1.17.1               h7f98852_1    conda-forge
ca-certificates           2021.5.30            ha878542_0    conda-forge
cached-property           1.5.2                hd8ed1ab_1    conda-forge
cached_property           1.5.2              pyha770c72_1    conda-forge
cairo                     1.16.0            h6cf1ce9_1008    conda-forge
certifi                   2021.5.30        py37h89c1867_0    conda-forge
cffi                      1.14.5           py37hc58025e_0    conda-forge
chardet                   4.0.0            py37h89c1867_1    conda-forge
click                     8.0.1            py37h89c1867_0    conda-forge
click-completion          0.5.2            py37h89c1867_2    conda-forge
click-log                 0.3.2              pyh9f0ad1d_0    conda-forge
cloud-sptheme             1.10.1.post20200504175005          pypi_0    pypi
codecov                   2.1.11             pyhd3deb0d_0    conda-forge
colorama                  0.4.4              pyh9f0ad1d_0    conda-forge
commonmark                0.9.1                      py_0    conda-forge
coverage                  5.5              py37h5e8e339_0    conda-forge
cryptography              3.4.7            py37h5d9358c_0    conda-forge
css-html-js-minify        2.5.5            py37h89c1867_3    conda-forge
cudatoolkit               11.2.2               he111cf0_8    conda-forge
curl                      7.77.0               hea6ffbf_0    conda-forge
cycler                    0.10.0                     py_2    conda-forge
cython                    0.29.23          py37hcd2ae1e_0    conda-forge
dataclasses               0.8                pyhc8e2a94_1    conda-forge
decorator                 4.4.2                      py_0    conda-forge
defusedxml                0.7.1              pyhd8ed1ab_0    conda-forge
docstring-parser          0.7.3                    pypi_0    pypi
docutils                  0.17.1           py37h89c1867_0    conda-forge
entrypoints               0.3             pyhd8ed1ab_1003    conda-forge
fftw                      3.3.9           nompi_h74d3f13_101    conda-forge
fontconfig                2.13.1            hba837de_1005    conda-forge
freetype                  2.10.4               h0708190_1    conda-forge
future                    0.18.2           py37h89c1867_3    conda-forge
gettext                   0.19.8.1          h0b5b191_1005    conda-forge
gflags                    2.2.2             he1b5a44_1004    conda-forge
gitdb                     4.0.7              pyhd8ed1ab_0    conda-forge
gitpython                 3.1.18             pyhd8ed1ab_0    conda-forge
glog                      0.5.0                h48cff8f_0    conda-forge
greenlet                  1.1.0            py37hcd2ae1e_0    conda-forge
gromacs                   2021.1          nompi_hcc3dda7_101    bioconda
grpc-cpp                  1.38.0               h2a12866_1    conda-forge
h5py                      3.2.1           nompi_py37ha3df211_100    conda-forge
hdf4                      4.2.15               h10796ff_3    conda-forge
hdf5                      1.10.6          nompi_h6a2412b_1114    conda-forge
icu                       68.1                 h58526e2_0    conda-forge
idna                      2.10               pyh9f0ad1d_0    conda-forge
imagesize                 1.2.0                      py_0    conda-forge
importlib-metadata        4.5.0            py37h89c1867_0    conda-forge
importlib_metadata        4.5.0                hd8ed1ab_0    conda-forge
importlib_resources       5.1.4              pyhd8ed1ab_0    conda-forge
iniconfig                 1.1.1              pyh9f0ad1d_0    conda-forge
ipykernel                 5.5.5            py37h085eea5_0    conda-forge
ipython                   7.24.1           py37h085eea5_0    conda-forge
ipython_genutils          0.2.0                      py_1    conda-forge
ipywidgets                7.6.3              pyhd3deb0d_0    conda-forge
jbig                      2.1               h7f98852_2003    conda-forge
jedi                      0.18.0           py37h89c1867_2    conda-forge
jinja2                    2.11.3             pyh44b312d_0    conda-forge
joblib                    1.0.1              pyhd8ed1ab_0    conda-forge
jpeg                      9d                   h36c2ea0_0    conda-forge
jsonschema                3.2.0              pyhd8ed1ab_3    conda-forge
jupyter-cache             0.4.2              pyhd8ed1ab_0    conda-forge
jupyter-server-mathjax    0.2.3              pyhd8ed1ab_0    conda-forge
jupyter-sphinx            0.3.2                      py_0    conda-forge
jupyter_client            6.1.12             pyhd8ed1ab_0    conda-forge
jupyter_core              4.7.1            py37h89c1867_0    conda-forge
jupyter_server            1.8.0              pyhd8ed1ab_0    conda-forge
jupyterlab_pygments       0.1.2              pyh9f0ad1d_0    conda-forge
jupyterlab_widgets        1.0.0              pyhd8ed1ab_1    conda-forge
kiwisolver                1.3.1            py37h2527ec5_1    conda-forge
krb5                      1.19.1               hcc1bbae_0    conda-forge
latexcodec                2.0.1              pyh9f0ad1d_0    conda-forge
lcms2                     2.12                 hddcbb42_0    conda-forge
ld_impl_linux-64          2.35.1               hea4e1c9_2    conda-forge
lerc                      2.2.1                h9c3ff4c_0    conda-forge
libblas                   3.9.0                9_openblas    conda-forge
libcblas                  3.9.0                9_openblas    conda-forge
libcurl                   7.77.0               h2574ce0_0    conda-forge
libdeflate                1.7                  h7f98852_5    conda-forge
libedit                   3.1.20191231         he28a2e2_2    conda-forge
libev                     4.33                 h516909a_1    conda-forge
libevent                  2.1.10               hcdb4288_3    conda-forge
libffi                    3.3                  h58526e2_2    conda-forge
libgcc-ng                 9.3.0               h2828fa1_19    conda-forge
libgfortran-ng            9.3.0               hff62375_19    conda-forge
libgfortran5              9.3.0               hff62375_19    conda-forge
libglib                   2.68.2               h3e27bee_2    conda-forge
libgomp                   9.3.0               h2828fa1_19    conda-forge
libhwloc                  2.4.1                h6746aa3_0    conda-forge
libiconv                  1.16                 h516909a_0    conda-forge
liblapack                 3.9.0                9_openblas    conda-forge
libnetcdf                 4.8.0           nompi_hcd642e3_103    conda-forge
libnghttp2                1.43.0               h812cca2_0    conda-forge
libopenblas               0.3.15          pthreads_h8fe5266_1    conda-forge
libpng                    1.6.37               h21135ba_2    conda-forge
libprotobuf               3.16.0               h780b84a_0    conda-forge
libsass                   0.20.1           py37hcd2ae1e_2    conda-forge
libsodium                 1.0.18               h36c2ea0_1    conda-forge
libssh2                   1.9.0                ha56f1ee_6    conda-forge
libstdcxx-ng              9.3.0               h6de172a_19    conda-forge
libthrift                 0.14.1               he6d91bd_1    conda-forge
libtiff                   4.3.0                hf544144_1    conda-forge
libutf8proc               2.6.1                h7f98852_0    conda-forge
libuuid                   2.32.1            h7f98852_1000    conda-forge
libwebp-base              1.2.0                h7f98852_2    conda-forge
libxcb                    1.13              h7f98852_1003    conda-forge
libxml2                   2.9.12               h72842e0_0    conda-forge
libxslt                   1.1.33               h15afd5d_2    conda-forge
libzip                    1.7.3                h4de3113_0    conda-forge
livereload                2.6.3              pyh9f0ad1d_0    conda-forge
lunr                      0.5.8              pyhd8ed1ab_2    conda-forge
lxml                      4.6.3            py37h77fd288_0    conda-forge
lz4-c                     1.9.3                h9c3ff4c_0    conda-forge
lzo                       2.10              h516909a_1000    conda-forge
markdown                  3.3.4              pyhd8ed1ab_0    conda-forge
markdown-it-py            0.6.2              pyhd8ed1ab_0    conda-forge
markupsafe                2.0.1            py37h5e8e339_0    conda-forge
matplotlib-base           3.4.2            py37hdd32ed1_0    conda-forge
matplotlib-inline         0.1.2              pyhd8ed1ab_2    conda-forge
mdit-py-plugins           0.2.6              pyhd8ed1ab_0    conda-forge
mdtraj                    1.9.5            py37hd0d7e5a_1    conda-forge
mistune                   0.8.4           py37h5e8e339_1003    conda-forge
mkapi                     1.0.14             pyhd8ed1ab_0    conda-forge
mkdocs                    1.1.2                      py_0    conda-forge
mkdocs-autorefs           0.2.1              pyhd8ed1ab_0    conda-forge
mkdocs-material           7.1.7              pyhd8ed1ab_0    conda-forge
mkdocs-material-extensions 1.0.1                      py_0    conda-forge
mkdocstrings              0.15.1             pyhd8ed1ab_0    conda-forge
mmpbsa-py                 16.0                     pypi_0    pypi
mock                      4.0.3            py37h89c1867_1    conda-forge
more-itertools            8.8.0              pyhd8ed1ab_0    conda-forge
msgpack-python            1.0.2            py37h2527ec5_1    conda-forge
myst-nb                   0.12.3             pyhd8ed1ab_0    conda-forge
myst-parser               0.13.7             pyhd8ed1ab_0    conda-forge
nbclient                  0.5.3              pyhd8ed1ab_0    conda-forge
nbconvert                 5.6.1            py37hc8dfbb8_1    conda-forge
nbdime                    3.1.0              pyhd8ed1ab_0    conda-forge
nbformat                  5.1.3              pyhd8ed1ab_0    conda-forge
nbsphinx                  0.8.5              pyhd8ed1ab_1    conda-forge
nbsphinx-link             1.3.0              pyh9f0ad1d_0    conda-forge
nbval                     0.9.6              pyh9f0ad1d_0    conda-forge
ncurses                   6.2                  h58526e2_4    conda-forge
nest-asyncio              1.5.1              pyhd8ed1ab_0    conda-forge
netcdf-fortran            4.5.3           nompi_hf3f1587_104    conda-forge
networkx                  2.5.1              pyhd8ed1ab_0    conda-forge
nglview                   3.0.1              pyh59e0f4d_0    conda-forge
nltk                      3.6.2              pyhd8ed1ab_0    conda-forge
notebook                  6.4.0              pyha770c72_0    conda-forge
numexpr                   2.7.3            py37hdc94413_0    conda-forge
numpy                     1.20.2           py37h038b26d_0    conda-forge
numpydoc                  1.1.0                      py_1    conda-forge
ocl-icd                   2.3.0                h7f98852_0    conda-forge
ocl-icd-system            1.0.0                         1    conda-forge
olefile                   0.46               pyh9f0ad1d_1    conda-forge
openeye-toolkits          2020.2.2                 py37_0    openeye
openff-evaluator          0.3.4+0.g6e6f0a4.dirty           dev_0    <develop>
openff-forcefields        1.3.0              pyh44b312d_0    conda-forge
openff-sphinx-theme       0.0.32+57.gdf5714d.dirty           dev_0    <develop>
openff-toolkit            0.9.2+37.g7b6bfee4.dirty           dev_0    <develop>
openjpeg                  2.4.0                hb52868f_1    conda-forge
openmm                    7.5.1            py37h96c4ddf_0    conda-forge
openmmforcefields         0.9.0              pyhd8ed1ab_0    conda-forge
openssl                   1.1.1k               h7f98852_0    conda-forge
orc                       1.6.8                h58a87f1_0    conda-forge
packaging                 20.9               pyh44b312d_0    conda-forge
packmol                   20.010               h86c2bf4_0    conda-forge
packmol-memgen            1.1.0rc0                 pypi_0    pypi
pandas                    1.2.4            py37h219a48f_0    conda-forge
pandoc                    2.14.0.1             h7f98852_0    conda-forge
pandocfilters             1.4.2                      py_1    conda-forge
parmed                    3.4.1            py37hcd2ae1e_0    conda-forge
parquet-cpp               1.5.1                         2    conda-forge
parso                     0.8.2              pyhd8ed1ab_0    conda-forge
pcre                      8.44                 he1b5a44_0    conda-forge
pdb4amber                 20.1                     pypi_0    pypi
perl                      5.32.1          0_h7f98852_perl5    conda-forge
pexpect                   4.8.0              pyh9f0ad1d_2    conda-forge
pickleshare               0.7.5                   py_1003    conda-forge
pillow                    8.2.0            py37h4600e1f_1    conda-forge
pint                      0.17               pyhd8ed1ab_0    conda-forge
pip                       21.1.1             pyhd8ed1ab_0    conda-forge
pixman                    0.40.0               h36c2ea0_0    conda-forge
plotly                    4.14.3             pyh44b312d_0    conda-forge
pluggy                    0.13.1           py37h89c1867_4    conda-forge
prometheus_client         0.11.0             pyhd8ed1ab_0    conda-forge
prompt-toolkit            3.0.18             pyha770c72_0    conda-forge
psutil                    5.8.0            py37h5e8e339_1    conda-forge
pthread-stubs             0.4               h36c2ea0_1001    conda-forge
ptyprocess                0.7.0              pyhd3deb0d_0    conda-forge
py                        1.10.0             pyhd3deb0d_0    conda-forge
py-cpuinfo                8.0.0              pyhd8ed1ab_0    conda-forge
pyarrow                   4.0.1           py37he2832ee_1_cpu    conda-forge
pybtex                    0.24.0           py37h89c1867_0    conda-forge
pybtex-docutils           1.0.0            py37h89c1867_0    conda-forge
pycairo                   1.20.1           py37hfff247e_0    conda-forge
pycparser                 2.20               pyh9f0ad1d_2    conda-forge
pydantic                  1.8.2            py37h5e8e339_0    conda-forge
pygments                  2.9.0              pyhd8ed1ab_0    conda-forge
pymdown-extensions        8.2                pyhd8ed1ab_0    conda-forge
pyopenssl                 20.0.1             pyhd8ed1ab_0    conda-forge
pyparsing                 2.4.7              pyh9f0ad1d_0    conda-forge
pyrsistent                0.17.3           py37h5e8e339_2    conda-forge
pysocks                   1.7.1            py37h89c1867_3    conda-forge
pytables                  3.6.1            py37h0c4f3e0_3    conda-forge
pytest                    6.2.4            py37h89c1867_0    conda-forge
pytest-cov                2.12.0             pyhd8ed1ab_0    conda-forge
python                    3.7.10          hffdb5ce_100_cpython    conda-forge
python-dateutil           2.8.1                      py_0    conda-forge
python-slugify            5.0.2              pyhd8ed1ab_0    conda-forge
python_abi                3.7                     1_cp37m    conda-forge
pytkdocs                  0.11.1           py37h89c1867_0    conda-forge
pytraj                    2.0.6                    pypi_0    pypi
pytz                      2021.1             pyhd8ed1ab_0    conda-forge
pyyaml                    5.4.1            py37h5e8e339_0    conda-forge
pyzmq                     22.1.0           py37h336d617_0    conda-forge
qcelemental               0.19.0             pyhd8ed1ab_0    conda-forge
qcengine                  0.18.0             pyhd8ed1ab_0    conda-forge
qcportal                  0.14.0                     py_1    conda-forge
rdkit                     2021.03.2        py37haf5a968_0    conda-forge
re2                       2021.06.01           h9c3ff4c_0    conda-forge
readline                  8.1                  h46c0cb4_0    conda-forge
recommonmark              0.7.1              pyhd8ed1ab_0    conda-forge
regex                     2021.4.4         py37h5e8e339_0    conda-forge
reportlab                 3.5.67           py37h69800bb_0    conda-forge
requests                  2.25.1             pyhd3deb0d_0    conda-forge
retrying                  1.3.3                      py_2    conda-forge
s2n                       1.0.9                h9b69904_0    conda-forge
sander                    16.0                     pypi_0    pypi
scipy                     1.6.3            py37h29e03ee_0    conda-forge
send2trash                1.5.0                      py_0    conda-forge
setuptools                49.6.0           py37h89c1867_3    conda-forge
shellingham               1.4.0              pyh44b312d_0    conda-forge
six                       1.16.0             pyh6c4a22f_0    conda-forge
slugify                   0.0.1                      py_2    conda-forge
smirnoff99frosst          1.1.0              pyh44b312d_0    conda-forge
smmap                     3.0.5              pyh44b312d_0    conda-forge
snappy                    1.1.8                he1b5a44_3    conda-forge
sniffio                   1.2.0            py37h89c1867_1    conda-forge
snowballstemmer           2.1.0              pyhd8ed1ab_0    conda-forge
soupsieve                 2.0.1                      py_1    conda-forge
sphinx                    3.5.3              pyhd8ed1ab_0    conda-forge
sphinx-autobuild          2021.3.14          pyhd8ed1ab_0    conda-forge
sphinx-press-theme        0.6.1                    pypi_0    pypi
sphinx-togglebutton       0.2.3              pyhd3deb0d_0    conda-forge
sphinx_bootstrap_theme    0.8.0                      py_0    conda-forge
sphinxcontrib-applehelp   1.0.2                      py_0    conda-forge
sphinxcontrib-bibtex      2.3.0              pyhd8ed1ab_0    conda-forge
sphinxcontrib-devhelp     1.0.2                      py_0    conda-forge
sphinxcontrib-htmlhelp    2.0.0              pyhd8ed1ab_0    conda-forge
sphinxcontrib-jsmath      1.0.1                      py_0    conda-forge
sphinxcontrib-qthelp      1.0.3                      py_0    conda-forge
sphinxcontrib-serializinghtml 1.1.5              pyhd8ed1ab_0    conda-forge
sqlalchemy                1.4.17           py37h5e8e339_0    conda-forge
sqlite                    3.35.5               h74cdb3f_0    conda-forge
terminado                 0.10.0           py37h89c1867_0    conda-forge
testpath                  0.3.1                    py37_1    conda-forge
text-unidecode            1.3                        py_0    conda-forge
tinydb                    4.4.0              pyh44b312d_0    conda-forge
tk                        8.6.10               h21135ba_1    conda-forge
toml                      0.10.2             pyhd8ed1ab_0    conda-forge
tornado                   6.1              py37h5e8e339_1    conda-forge
tqdm                      4.61.0             pyhd8ed1ab_0    conda-forge
traitlets                 5.0.5                      py_0    conda-forge
typing-extensions         3.10.0.0             hd8ed1ab_0    conda-forge
typing_extensions         3.10.0.0           pyha770c72_0    conda-forge
unidecode                 1.2.0              pyhd8ed1ab_0    conda-forge
urllib3                   1.26.5             pyhd8ed1ab_0    conda-forge
wcwidth                   0.2.5              pyh9f0ad1d_2    conda-forge
webencodings              0.5.1                      py_1    conda-forge
websocket-client          0.58.0           py37h06a4308_4  
wheel                     0.36.2             pyhd3deb0d_0    conda-forge
widgetsnbextension        3.5.1            py37h89c1867_4    conda-forge
xmltodict                 0.12.0                     py_0    conda-forge
xorg-kbproto              1.0.7             h7f98852_1002    conda-forge
xorg-libice               1.0.10               h7f98852_0    conda-forge
xorg-libsm                1.2.3             hd9c2040_1000    conda-forge
xorg-libx11               1.7.2                h7f98852_0    conda-forge
xorg-libxau               1.0.9                h7f98852_0    conda-forge
xorg-libxdmcp             1.1.3                h7f98852_0    conda-forge
xorg-libxext              1.3.4                h7f98852_1    conda-forge
xorg-libxrender           0.9.10            h7f98852_1003    conda-forge
xorg-libxt                1.2.1                h7f98852_2    conda-forge
xorg-renderproto          0.11.1            h7f98852_1002    conda-forge
xorg-xextproto            7.3.0             h7f98852_1002    conda-forge
xorg-xproto               7.0.31            h7f98852_1007    conda-forge
xz                        5.2.5                h516909a_1    conda-forge
yaml                      0.2.5                h516909a_0    conda-forge
zeromq                    4.3.4                h9c3ff4c_0    conda-forge
zipp                      3.4.1              pyhd8ed1ab_0    conda-forge
zlib                      1.2.11            h516909a_1010    conda-forge
zstd                      1.5.0                ha95c52a_0    conda-forge
</pre></details>

**Additional context**
<!-- Add any other context about the problem here. -->
The offending call to `from_rdkit` is made here:

https://github.com/openforcefield/openff-toolkit/blob/cb46142b84690103b60dbf2de12cd65bcd3d74ea/openff/toolkit/utils/toolkits.py#L2910-2915

Discussion:
j-wags: @Yoshanuikabundi -- You were right about the root cause and remediation for this error. I had initially, _incorrectly_, thought that the PDB molecule was being used for more of the graph molecule information than it really is. But in the process of discovering that _isn't_ the case, and that the molecule graph comes 100% from the SMILES, I figured it'd be easy to squelch the error and polish some things up. 

Could you review when you have time? I'm inclined to let this one go without a test since it doesn't concern an actual logic bug or fatal error, but I'll add one if you think we should. 
codecov[bot]: # [Codecov](https://codecov.io/gh/openforcefield/openff-toolkit/pull/1003?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openforcefield) Report
> Merging [#1003](https://codecov.io/gh/openforcefield/openff-toolkit/pull/1003?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openforcefield) (0c08e43) into [master](https://codecov.io/gh/openforcefield/openff-toolkit/commit/317180da3a1b2437a7411a008da468b452faa1d1?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openforcefield) (317180d) will **decrease** coverage by `8.45%`.
> The diff coverage is `100.00%`.

> :exclamation: Current head 0c08e43 differs from pull request most recent head b2222f1. Consider uploading reports for the commit b2222f1 to get more accurate results


Patch:
diff --git a/docs/releasehistory.md b/docs/releasehistory.md
index a3e0c974..c73b643d 100644
--- a/docs/releasehistory.md
+++ b/docs/releasehistory.md
@@ -46,10 +46,21 @@ Releases follow the `major.minor.micro` scheme recommended by [PEP440](https://w
     set to 0 with appropriate units, rather than `None`
 - [PR #956](https://github.com/openforcefield/openforcefield/pull/956): Added `ForceField.get_partial_charges()` 
   to more easily compute the partial charges assigned by a force field for a molecule.
+- [PR  #1006](https://github.com/openforcefield/openff-toolkit/pull/1006):
+  Two behavior changes in the SMILES output for `to_file()` and `to_file_obj()`:
+  - The RDKit and OpenEye wrappers now output the same SMILES as `to_smiles()`.
+   This uses explicit hydrogens rather than the toolkit's default of implicit hydrogens.
+  - The RDKit wrapper no longer includes a header line. This improves
+  the consistency between the OpenEye and RDKit outputs.
+
 
 
 ### Bugfixes
 
+- [PR #1003](https://github.com/openforcefield/openforcefield/pull/1003): Fixes
+  [Issue #1000](https://github.com/openforcefield/openff-toolkit/issues/1000), where a stereochemistry
+  warning is sometimes erroneously emitted when loading a stereogenic molecule using
+  [Molecule.from_pdb_and_smiles](openff.toolkit.topology.Molecule.from_pdb_and_smiles) 
 - [PR #1002](https://github.com/openforcefield/openforcefield/pull/1002): Fixes a bug in which OFFXML files could
   inadvertently be loaded from subdirectories.
 - [PR #969](https://github.com/openforcefield/openforcefield/pull/969): Fixes a bug in which the cutoff distance
@@ -58,6 +69,20 @@ Releases follow the `major.minor.micro` scheme recommended by [PEP440](https://w
   was not set to the value specified by the vdW and Electrostatics handlers.
 - [PR #909](https://github.com/openforcefield/openforcefield/pull/909): It is now possible to create an
   OpenMM system with virtual sites created via the `Molecule` virtual site API
+- [PR  #1006](https://github.com/openforcefield/openff-toolkit/pull/1006):
+  Many small fixes to the toolkit wrapper I/O for better error
+  handling, improved consistency between reading from a file vs. file
+  object, and improved consistency between the RDKit and OEChem
+  toolkit wrappers. For the full list see
+  [Issue #1005](https://github.com/openforcefield/openff-toolkit/issues/1005). Some
+  of the more significant fixes are:
+  - RDKitToolkitWrapper's `from_file_obj()` now uses the same
+    structure normaliation as `from_file()`.
+  - `from_smiles()` now raises an `openff.toolkit.utils.ParseError` if
+  the SMILES could not be parsed.
+  - OEChem input and output files now raise an OSError if the file
+  could not be opened.
+  - All input file object readers now support file objects open in binary mode.
 
 ### Examples added
 
diff --git a/openff/toolkit/topology/molecule.py b/openff/toolkit/topology/molecule.py
index e024edfe..cbd957c9 100644
--- a/openff/toolkit/topology/molecule.py
+++ b/openff/toolkit/topology/molecule.py
@@ -5470,18 +5470,20 @@ class FrozenMolecule(Serializable):
 
         The molecule is created and sanitised based on the SMILES string, we then find a mapping
         between this molecule and one from the PDB based only on atomic number and connections.
-        The SMILES molecule is then reindex to match the PDB, the conformer is attached and the
+        The SMILES molecule is then reindexed to match the PDB, the conformer is attached, and the
         molecule returned.
 
+        Note that any stereochemistry in the molecule is set by the SMILES, and not the coordinates
+        of the PDB.
+
         Parameters
         ----------
         file_path: str
             PDB file path
         smiles : str
-            a valid smiles string for the pdb, used for seterochemistry and bond order
-
+            a valid smiles string for the pdb, used for stereochemistry, formal charges, and bond order
         allow_undefined_stereo : bool, default=False
-            If false, raises an exception if oemol contains undefined stereochemistry.
+            If false, raises an exception if SMILES contains undefined stereochemistry.
 
         Returns
         --------
diff --git a/openff/toolkit/utils/exceptions.py b/openff/toolkit/utils/exceptions.py
index 615a622a..a8e2ca75 100644
--- a/openff/toolkit/utils/exceptions.py
+++ b/openff/toolkit/utils/exceptions.py
@@ -15,6 +15,7 @@ __all__ = (
     "ChargeCalculationError",
     "InvalidIUPACNameError",
     "AntechamberNotFoundError",
+    "ParseError",
 )
 
 # =============================================================================================
@@ -108,3 +109,7 @@ class InvalidIUPACNameError(MessageException):
 
 class AntechamberNotFoundError(MessageException):
     """The antechamber executable was not found"""
+
+
+class ParseError(MessageException, ValueError):
+    """The record couple not be parsed into the given format"""
diff --git a/openff/toolkit/utils/openeye_wrapper.py b/openff/toolkit/utils/openeye_wrapper.py
index d8fe820a..3bc1d997 100644
--- a/openff/toolkit/utils/openeye_wrapper.py
+++ b/openff/toolkit/utils/openeye_wrapper.py
@@ -12,6 +12,7 @@ __all__ = ("OpenEyeToolkitWrapper",)
 
 import importlib
 import logging
+import pathlib
 import re
 import tempfile
 from collections import defaultdict
@@ -32,10 +33,11 @@ from .exceptions import (
     GAFFAtomTypeWarning,
     InvalidIUPACNameError,
     LicenseError,
+    ParseError,
     ToolkitUnavailableException,
     UndefinedStereochemistryError,
 )
-from .utils import inherit_docstrings, temporary_cd
+from .utils import inherit_docstrings
 
 # =============================================================================================
 # CONFIGURE LOGGER
@@ -49,6 +51,20 @@ logger = logging.getLogger(__name__)
 # =============================================================================================
 
 
+def get_oeformat(file_format):
+    from openeye import oechem
+
+    file_format = file_format.upper()
+    # XXX This is what RDKit does. Should be supported here too?
+    if file_format == "MOL":
+        file_format = "SDF"
+
+    oeformat = getattr(oechem, "OEFormat_" + file_format, None)
+    if oeformat is None:
+        raise ValueError(f"Unsupported file format: {file_format}")
+    return oeformat
+
+
 @inherit_docstrings
 class OpenEyeToolkitWrapper(base_wrapper.ToolkitWrapper):
     """
@@ -266,7 +282,17 @@ class OpenEyeToolkitWrapper(base_wrapper.ToolkitWrapper):
         """
         from openeye import oechem
 
+        oeformat = get_oeformat(file_format)
         ifs = oechem.oemolistream(file_path)
+        if not ifs.IsValid():
+            # Get Python to report an error message, if possible.
+            # This can distinguish between FileNotFound, IsADirectoryError, etc.
+            open(file_path).close()
+            # If that worked, then who knows. Fail anyway.
+            raise OSError("Unable to open file")
+
+        ifs.SetFormat(oeformat)
+
         return self._read_oemolistream_molecules(
             ifs, allow_undefined_stereo, file_path=file_path, _cls=_cls
         )
@@ -307,7 +333,7 @@ class OpenEyeToolkitWrapper(base_wrapper.ToolkitWrapper):
         # Configure input molecule stream.
         ifs = oechem.oemolistream()
         ifs.openstring(file_obj.read())
-        oeformat = getattr(oechem, "OEFormat_" + file_format)
+        oeformat = get_oeformat(file_format)
         ifs.SetFormat(oeformat)
 
         return self._read_oemolistream_molecules(ifs, allow_undefined_stereo, _cls=_cls)
@@ -326,11 +352,20 @@ class OpenEyeToolkitWrapper(base_wrapper.ToolkitWrapper):
             The format for writing the molecule data
 
         """
+        # This function requires a text-mode file_obj.
+        try:
+            file_obj.write("")
+        except TypeError:
+            # Switch to a ValueError and use a more informative exception
+            # message to match RDKit.
+            raise ValueError(
+                "Need a text mode file object like StringIO or a file opened with mode 't'"
+            ) from None
+
         with tempfile.TemporaryDirectory() as tmpdir:
-            with temporary_cd(tmpdir):
-                outfile = "temp_molecule." + file_format
-                self.to_file(molecule, outfile, file_format)
-                file_data = open(outfile).read()
+            path = pathlib.Path(tmpdir, f"input.{file_format.lower()}")
+            self.to_file(molecule, str(path), file_format)
+            file_data = path.read_text()
             file_obj.write(file_data)
 
     def to_file(self, molecule, file_path, file_format):
@@ -351,9 +386,22 @@ class OpenEyeToolkitWrapper(base_wrapper.ToolkitWrapper):
 
         oemol = self.to_openeye(molecule)
         ofs = oechem.oemolostream(file_path)
-        openeye_format = getattr(oechem, "OEFormat_" + file_format.upper())
+        if not ofs.IsValid():
+            # Get Python to report an error message, if possible.
+            # This can distinguish between PermissionError, IsADirectoryError, etc.
+            open(file_path, "wb").close()
+            # If that worked, then who knows. Fail anyway.
+            raise OSError("Unable to open file")
+
+        openeye_format = get_oeformat(file_format)
         ofs.SetFormat(openeye_format)
 
+        if openeye_format == oechem.OEFormat_SMI:
+            ofs.SetFlavor(
+                openeye_format,
+                self._get_smiles_flavor(isomeric=True, explicit_hydrogens=True),
+            )
+
         # OFFTK strictly treats SDF as a single-conformer format.
         # We need to override OETK's behavior here if the user is saving a multiconformer molecule.
 
@@ -1219,6 +1267,31 @@ class OpenEyeToolkitWrapper(base_wrapper.ToolkitWrapper):
 
         return oemol
 
+    def _get_smiles_flavor(self, isomeric, explicit_hydrogens):
+        from openeye import oechem
+
+        # this sets up the default settings following the old DEFAULT flag
+        # more information on flags can be found here
+        # <https://docs.eyesopen.com/toolkits/python/oechemtk/OEChemConstants/OESMILESFlag.html#OEChem::OESMILESFlag>
+        smiles_options = (
+            oechem.OESMILESFlag_Canonical
+            | oechem.OESMILESFlag_Isotopes
+            | oechem.OESMILESFlag_RGroups
+        )
+
+        # check if we want an isomeric smiles
+        if isomeric:
+            # add the atom and bond stereo flags
+            smiles_options |= (
+                oechem.OESMILESFlag_AtomStereo | oechem.OESMILESFlag_BondStereo
+            )
+
+        if explicit_hydrogens:
+            # add the hydrogen flag
+            smiles_options |= oechem.OESMILESFlag_Hydrogens
+
+        return smiles_options
+
     def to_smiles(self, molecule, isomeric=True, explicit_hydrogens=True, mapped=False):
         """
         Uses the OpenEye toolkit to convert a Molecule into a SMILES string.
@@ -1248,25 +1321,7 @@ class OpenEyeToolkitWrapper(base_wrapper.ToolkitWrapper):
 
         oemol = self.to_openeye(molecule)
 
-        # this sets up the default settings following the old DEFAULT flag
-        # more information on flags can be found here
-        # <https://docs.eyesopen.com/toolkits/python/oechemtk/OEChemConstants/OESMILESFlag.html#OEChem::OESMILESFlag>
-        smiles_options = (
-            oechem.OESMILESFlag_Canonical
-            | oechem.OESMILESFlag_Isotopes
-            | oechem.OESMILESFlag_RGroups
-        )
-
-        # check if we want an isomeric smiles
-        if isomeric:
-            # add the atom and bond stereo flags
-            smiles_options |= (
-                oechem.OESMILESFlag_AtomStereo | oechem.OESMILESFlag_BondStereo
-            )
-
-        if explicit_hydrogens:
-            # add the hydrogen flag
-            smiles_options |= oechem.OESMILESFlag_Hydrogens
+        smiles_options = self._get_smiles_flavor(isomeric, explicit_hydrogens)
 
         if mapped:
             assert explicit_hydrogens is True, (
@@ -1488,7 +1543,8 @@ class OpenEyeToolkitWrapper(base_wrapper.ToolkitWrapper):
         from openeye import oechem
 
         oemol = oechem.OEGraphMol()
-        oechem.OESmilesToMol(oemol, smiles)
+        if not oechem.OESmilesToMol(oemol, smiles):
+            raise ParseError("Unable to parse the SMILES string")
         if not (hydrogens_are_explicit):
             result = oechem.OEAddExplicitHydrogens(oemol)
             if not result:
diff --git a/openff/toolkit/utils/rdkit_wrapper.py b/openff/toolkit/utils/rdkit_wrapper.py
index a0494ef5..f6eefcfa 100644
--- a/openff/toolkit/utils/rdkit_wrapper.py
+++ b/openff/toolkit/utils/rdkit_wrapper.py
@@ -13,6 +13,7 @@ import copy
 import importlib
 import itertools
 import logging
+import tempfile
 from typing import TYPE_CHECKING, List, Optional, Tuple
 
 import numpy as np
@@ -25,6 +26,7 @@ from . import base_wrapper
 from .constants import DEFAULT_AROMATICITY_MODEL
 from .exceptions import (
     ChargeMethodUnavailableError,
+    ParseError,
     ToolkitUnavailableException,
     UndefinedStereochemistryError,
 )
@@ -40,6 +42,21 @@ logger = logging.getLogger(__name__)
 # =============================================================================================
 
 
+def normalize_file_format(file_format):
+    return file_format.upper()
+
+
+def _require_text_file_obj(file_obj):
+    try:
+        file_obj.write("")
+    except TypeError:
+        # Switch to a ValueError and use a more informative exception
+        # message to match RDKit's toolkit writer.
+        raise ValueError(
+            "Need a text mode file object like StringIO or a file opened with mode 't'"
+        ) from None
+
+
 class RDKitToolkitWrapper(base_wrapper.ToolkitWrapper):
     """
     RDKit toolkit wrapper
@@ -75,7 +92,7 @@ class RDKitToolkitWrapper(base_wrapper.ToolkitWrapper):
             self._toolkit_file_write_formats = {
                 "SDF": Chem.SDWriter,
                 "MOL": Chem.SDWriter,
-                "SMI": Chem.SmilesWriter,
+                "SMI": None,  # Special support to use to_smiles() instead of RDKit's SmilesWriter
                 "PDB": Chem.PDBWriter,
                 "TDT": Chem.TDTWriter,
             }
@@ -156,18 +173,20 @@ class RDKitToolkitWrapper(base_wrapper.ToolkitWrapper):
 
         The molecule is created and sanitised based on the SMILES string, we then find a mapping
         between this molecule and one from the PDB based only on atomic number and connections.
-        The SMILES molecule is then reindex to match the PDB, the conformer is attached and the
+        The SMILES molecule is then reindexed to match the PDB, the conformer is attached, and the
         molecule returned.
 
+        Note that any stereochemistry in the molecule is set by the SMILES, and not the coordinates
+        of the PDB.
+
         Parameters
         ----------
         file_path: str
             PDB file path
         smiles : str
-            a valid smiles string for the pdb, used for seterochemistry and bond order
-
+            a valid smiles string for the pdb, used for stereochemistry, formal charges, and bond order
         allow_undefined_stereo : bool, default=False
-            If false, raises an exception if oemol contains undefined stereochemistry.
+            If false, raises an exception if SMILES contains undefined stereochemistry.
         _cls : class
             Molecule constructor
 
@@ -188,13 +207,18 @@ class RDKitToolkitWrapper(base_wrapper.ToolkitWrapper):
             smiles, allow_undefined_stereo=allow_undefined_stereo, _cls=_cls
         )
 
-        # Make another molecule from the PDB, allow stero errors here they are expected
+        # Make another molecule from the PDB. We squelch stereo errors here, since
+        # RDKit's PDB loader doesn't attempt to perceive stereochemistry, bond order,
+        # or formal charge (and we don't need those here).
+        prev_log_level = logger.getEffectiveLevel()
+        logger.setLevel(logging.ERROR)
         pdbmol = self.from_rdkit(
             Chem.MolFromPDBFile(file_path, removeHs=False),
             allow_undefined_stereo=True,
             hydrogens_are_explicit=True,
             _cls=_cls,
         )
+        logger.setLevel(prev_log_level)
 
         # check isomorphic and get the mapping if true the mapping will be
         # Dict[pdb_index: offmol_index] sorted by pdb_index
@@ -222,6 +246,32 @@ class RDKitToolkitWrapper(base_wrapper.ToolkitWrapper):
 
             raise InvalidConformerError("The PDB and SMILES structures do not match.")
 
+    def _process_sdf_supplier(self, sdf_supplier, allow_undefined_stereo, _cls):
+        "Helper function to process RDKit molecules from an SDF input source"
+        from rdkit import Chem
+
+        for rdmol in sdf_supplier:
+            if rdmol is None:
+                continue
+
+            # Sanitize the molecules (fails on nitro groups)
+            try:
+                Chem.SanitizeMol(
+                    rdmol,
+                    Chem.SANITIZE_ALL
+                    ^ Chem.SANITIZE_SETAROMATICITY
+                    ^ Chem.SANITIZE_ADJUSTHS,
+                )
+                Chem.AssignStereochemistryFrom3D(rdmol)
+            except ValueError as e:
+                logger.warning(rdmol.GetProp("_Name") + " " + str(e))
+                continue
+            Chem.SetAromaticity(rdmol, Chem.AromaticityModel.AROMATICITY_MDL)
+            mol = self.from_rdkit(
+                rdmol, allow_undefined_stereo=allow_undefined_stereo, _cls=_cls
+            )
+            yield mol
+
     def from_file(
         self, file_path, file_format, allow_undefined_stereo=False, _cls=None
     ):
@@ -250,33 +300,20 @@ class RDKitToolkitWrapper(base_wrapper.ToolkitWrapper):
         """
         from rdkit import Chem
 
-        file_format = file_format.upper()
+        file_format = normalize_file_format(file_format)
 
         mols = list()
         if (file_format == "MOL") or (file_format == "SDF"):
-            for rdmol in Chem.SupplierFromFilename(
+            sdf_supplier = Chem.ForwardSDMolSupplier(
                 file_path, removeHs=False, sanitize=False, strictParsing=True
-            ):
-                if rdmol is None:
-                    continue
-
-                # Sanitize the molecules (fails on nitro groups)
-                try:
-                    Chem.SanitizeMol(
-                        rdmol,
-                        Chem.SANITIZE_ALL
-                        ^ Chem.SANITIZE_SETAROMATICITY
-                        ^ Chem.SANITIZE_ADJUSTHS,
-                    )
-                    Chem.AssignStereochemistryFrom3D(rdmol)
-                except ValueError as e:
-                    logger.warning(rdmol.GetProp("_Name") + " " + str(e))
-                    continue
-                Chem.SetAromaticity(rdmol, Chem.AromaticityModel.AROMATICITY_MDL)
-                mol = self.from_rdkit(
-                    rdmol, allow_undefined_stereo=allow_undefined_stereo, _cls=_cls
+            )
+            mols.extend(
+                self._process_sdf_supplier(
+                    sdf_supplier,
+                    allow_undefined_stereo=allow_undefined_stereo,
+                    _cls=_cls,
                 )
-                mols.append(mol)
+            )
 
         elif file_format == "SMI":
             # TODO: We have to do some special stuff when we import SMILES (currently
@@ -284,6 +321,11 @@ class RDKitToolkitWrapper(base_wrapper.ToolkitWrapper):
             # worthwhile to parse the SMILES file ourselves and pass each SMILES
             # through the from_smiles function instead
             for rdmol in Chem.SmilesMolSupplier(file_path, titleLine=False):
+                if rdmol is None:
+                    # Skip any lines that could not be processed.
+                    # This is consistent with the SDF reader and with
+                    # the OpenEye toolkit wrapper.
+                    continue
                 rdmol = Chem.AddHs(rdmol)
                 mol = self.from_rdkit(
                     rdmol, allow_undefined_stereo=allow_undefined_stereo, _cls=_cls
@@ -305,6 +347,9 @@ class RDKitToolkitWrapper(base_wrapper.ToolkitWrapper):
             # mols.append(mol)
             # TODO: Add SMI, TDT(?) support
 
+        else:
+            raise ValueError(f"Unsupported file format: {file_format}")
+
         return mols
 
     def from_file_obj(
@@ -339,21 +384,41 @@ class RDKitToolkitWrapper(base_wrapper.ToolkitWrapper):
 
         mols = []
 
+        file_format = normalize_file_format(file_format)
+
         if (file_format == "MOL") or (file_format == "SDF"):
             # TODO: Iterate over all mols in file_data
-            for rdmol in Chem.ForwardSDMolSupplier(file_obj):
-                mol = self.from_rdkit(rdmol, _cls=_cls)
-                mols.append(mol)
+            sdf_supplier = Chem.ForwardSDMolSupplier(
+                file_obj, removeHs=False, sanitize=False, strictParsing=True
+            )
+            mols.extend(
+                self._process_sdf_supplier(
+                    sdf_supplier,
+                    allow_undefined_stereo=allow_undefined_stereo,
+                    _cls=_cls,
+                )
+            )
 
-        if file_format == "SMI":
-            # TODO: Find a cleaner way to parse SMILES lines
-            file_data = file_obj.read()
-            lines = [line.strip() for line in file_data.split("\n")]
-            # remove blank lines
-            lines.remove("")
-            for line in lines:
-                mol = self.from_smiles(line, _cls=_cls)
-                mols.append(mol)
+        elif file_format == "SMI":
+            # There's no good way to create a SmilesMolSuppler from a string
+            # other than to use a temporary file.
+            with tempfile.NamedTemporaryFile(suffix=".smi") as tmpfile:
+                content = file_obj.read()
+                if isinstance(content, str):
+                    # Backwards compatibility. Older versions of OpenFF supported
+                    # file objects in "t"ext mode, but not file objects
+                    # in "b"inary mode. Now we expect all input file objects
+                    # to handle binary mode, but don't want to break older code.
+                    tmpfile.write(content.encode("utf8"))
+                else:
+                    tmpfile.write(content)
+                tmpfile.flush()
+                return self.from_file(
+                    tmpfile.name,
+                    "SMI",
+                    allow_undefined_stereo=allow_undefined_stereo,
+                    _cls=_cls,
+                )
 
         elif file_format == "PDB":
             raise Exception(
@@ -366,6 +431,9 @@ class RDKitToolkitWrapper(base_wrapper.ToolkitWrapper):
             # rdmol = Chem.MolFromPDBBlock(file_data)
             # mol = Molecule.from_rdkit(rdmol, _cls=_cls)
             # mols.append(mol)
+
+        else:
+            raise ValueError(f"Unsupported file format: {file_format}")
         # TODO: TDT file support
         return mols
 
@@ -386,19 +454,29 @@ class RDKitToolkitWrapper(base_wrapper.ToolkitWrapper):
         -------
 
         """
+        file_format = normalize_file_format(file_format)
+        _require_text_file_obj(file_obj)
 
-        file_format = file_format.upper()
-        rdmol = self.to_rdkit(molecule)
-        try:
-            writer = self._toolkit_file_write_formats[file_format](file_obj)
-            writer.write(rdmol)
-            writer.close()
-        # if we can not write to that file type catch the error here
-        except KeyError:
-            raise ValueError(
-                f"The requested file type ({file_format}) is not supported to be written using "
-                f"RDKitToolkitWrapper."
-            )
+        if file_format == "SMI":
+            # Special case for SMILES
+            smiles = self.to_smiles(molecule)
+            name = molecule.name
+            if name is not None:
+                output_line = f"{smiles} {name}\n"
+            else:
+                output_line = f"{smiles}\n"
+            file_obj.write(output_line)
+        else:
+            try:
+                writer_func = self._toolkit_file_write_formats[file_format]
+            except KeyError:
+                raise ValueError(f"Unsupported file format: {file_format})") from None
+            rdmol = self.to_rdkit(molecule)
+            writer = writer_func(file_obj)
+            try:
+                writer.write(rdmol)
+            finally:
+                writer.close()
 
     def to_file(self, molecule, file_path, file_format):
         """
@@ -666,6 +744,9 @@ class RDKitToolkitWrapper(base_wrapper.ToolkitWrapper):
         from rdkit import Chem
 
         rdmol = Chem.MolFromSmiles(smiles, sanitize=False)
+        if rdmol is None:
+            raise ParseError("Unable to parse the SMILES string")
+
         # strip the atom map from the molecule if it has one
         # so we don't affect the sterochemistry tags
         for atom in rdmol.GetAtoms():
diff --git a/openff/toolkit/utils/toolkits.py b/openff/toolkit/utils/toolkits.py
index 062fe85b..e923f439 100644
--- a/openff/toolkit/utils/toolkits.py
+++ b/openff/toolkit/utils/toolkits.py
@@ -45,6 +45,7 @@ __all__ = (
     "ChargeCalculationError",
     "InvalidIUPACNameError",
     "AntechamberNotFoundError",
+    "ParseError",
     # base_wrapper
     "ToolkitWrapper",
     # builtin_wrapper
@@ -98,6 +99,7 @@ from .exceptions import (
     MessageException,
     MissingDependencyError,
     MissingPackageError,
+    ParseError,
     ToolkitUnavailableException,
     UndefinedStereochemistryError,
 )


Test patch:
diff --git a/openff/toolkit/tests/test_toolkit_io.py b/openff/toolkit/tests/test_toolkit_io.py
new file mode 100644
index 00000000..8acc9ac0
--- /dev/null
+++ b/openff/toolkit/tests/test_toolkit_io.py
@@ -0,0 +1,1213 @@
+#!/usr/bin/env python
+# =============================================================================================
+# MODULE DOCSTRING
+# =============================================================================================
+
+"""
+Tests for I/O functionality of the toolkit wrappers
+
+"""
+
+# =============================================================================================
+# GLOBAL IMPORTS
+# =============================================================================================
+
+import os
+import pathlib
+import sys
+import tempfile
+from io import BytesIO, StringIO
+
+import numpy as np
+import pytest
+from numpy.testing import assert_allclose
+from simtk import unit
+
+from openff.toolkit.tests import create_molecules
+from openff.toolkit.tests.utils import requires_openeye, requires_rdkit
+from openff.toolkit.topology.molecule import Molecule
+from openff.toolkit.utils import OpenEyeToolkitWrapper, RDKitToolkitWrapper, exceptions
+
+# ================================================================
+# Data records used for testing.
+# ================================================================
+
+ETHANOL = create_molecules.create_ethanol()
+ETHANOL.name = "ethanol"
+
+# ========================================================
+# Various records for caffeine
+# ========================================================
+
+# From https://www.ebi.ac.uk/chembl/compound_report_card/CHEMBL113/
+CAFFEINE_2D_SDF = """\
+caffeine
+     RDKit          2D
+
+ 14 15  0  0  0  0  0  0  0  0999 V2000
+   -1.1875   -9.6542    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -1.1875   -8.9625    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -1.8125  -10.0292    0.0000 N   0  0  0  0  0  0  0  0  0  0  0  0
+   -2.4167   -8.9625    0.0000 N   0  0  0  0  0  0  0  0  0  0  0  0
+   -2.4167   -9.6542    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -1.8125   -8.6000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -0.5000   -9.8917    0.0000 N   0  0  0  0  0  0  0  0  0  0  0  0
+   -0.5000   -8.7625    0.0000 N   0  0  0  0  0  0  0  0  0  0  0  0
+   -0.1125   -9.3042    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -3.0250  -10.0375    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
+   -1.8125   -7.8917    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
+   -1.8125  -10.7417    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -3.0250   -8.6000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -0.2917   -8.0750    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+  2  1  2  0
+  3  1  1  0
+  4  5  1  0
+  5  3  1  0
+  6  2  1  0
+  7  1  1  0
+  8  2  1  0
+  9  7  2  0
+ 10  5  2  0
+ 11  6  2  0
+ 12  3  1  0
+ 13  4  1  0
+ 14  8  1  0
+  9  8  1  0
+  4  6  1  0
+M  END
+
+> <chembl_id>
+CHEMBL113
+
+> <chembl_pref_name>
+CAFFEINE
+
+$$$$
+"""
+
+CAFFEINE_2D_COORDS = (
+    np.array(
+        [
+            (-1.1875, -9.6542, 0.0000),
+            (-1.1875, -8.9625, 0.0000),
+            (-1.8125, -10.0292, 0.0000),
+            (-2.4167, -8.9625, 0.0000),
+            (-2.4167, -9.6542, 0.0000),
+            (-1.8125, -8.6000, 0.0000),
+            (-0.5000, -9.8917, 0.0000),
+            (-0.5000, -8.7625, 0.0000),
+            (-0.1125, -9.3042, 0.0000),
+            (-3.0250, -10.0375, 0.0000),
+            (-1.8125, -7.8917, 0.0000),
+            (-1.8125, -10.7417, 0.0000),
+            (-3.0250, -8.6000, 0.0000),
+            (-0.2917, -8.0750, 0.0000),
+        ],
+        np.double,
+    )
+    * unit.angstrom
+)
+
+
+# From https://www.ebi.ac.uk/chembl/compound_report_card/CHEMBL113/
+CAFFEINE_SMI = "Cn1c(=O)c2c(ncn2C)n(C)c1=O CHEMBL113\n"
+
+# From https://pubchem.ncbi.nlm.nih.gov/compound/2519#section=2D-Structure
+CAFFEINE_3D_SDF = """\
+2519
+  -OEChem-07012107543D
+
+ 24 25  0     0  0  0  0  0  0999 V2000
+    0.4700    2.5688    0.0006 O   0  0  0  0  0  0  0  0  0  0  0  0
+   -3.1271   -0.4436   -0.0003 O   0  0  0  0  0  0  0  0  0  0  0  0
+   -0.9686   -1.3125    0.0000 N   0  0  0  0  0  0  0  0  0  0  0  0
+    2.2182    0.1412   -0.0003 N   0  0  0  0  0  0  0  0  0  0  0  0
+   -1.3477    1.0797   -0.0001 N   0  0  0  0  0  0  0  0  0  0  0  0
+    1.4119   -1.9372    0.0002 N   0  0  0  0  0  0  0  0  0  0  0  0
+    0.8579    0.2592   -0.0008 C   0  0  0  0  0  0  0  0  0  0  0  0
+    0.3897   -1.0264   -0.0004 C   0  0  0  0  0  0  0  0  0  0  0  0
+    0.0307    1.4220   -0.0006 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -1.9061   -0.2495   -0.0004 C   0  0  0  0  0  0  0  0  0  0  0  0
+    2.5032   -1.1998    0.0003 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -1.4276   -2.6960    0.0008 C   0  0  0  0  0  0  0  0  0  0  0  0
+    3.1926    1.2061    0.0003 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -2.2969    2.1881    0.0007 C   0  0  0  0  0  0  0  0  0  0  0  0
+    3.5163   -1.5787    0.0008 H   0  0  0  0  0  0  0  0  0  0  0  0
+   -1.0451   -3.1973   -0.8937 H   0  0  0  0  0  0  0  0  0  0  0  0
+   -2.5186   -2.7596    0.0011 H   0  0  0  0  0  0  0  0  0  0  0  0
+   -1.0447   -3.1963    0.8957 H   0  0  0  0  0  0  0  0  0  0  0  0
+    4.1992    0.7801    0.0002 H   0  0  0  0  0  0  0  0  0  0  0  0
+    3.0468    1.8092   -0.8992 H   0  0  0  0  0  0  0  0  0  0  0  0
+    3.0466    1.8083    0.9004 H   0  0  0  0  0  0  0  0  0  0  0  0
+   -1.8087    3.1651   -0.0003 H   0  0  0  0  0  0  0  0  0  0  0  0
+   -2.9322    2.1027    0.8881 H   0  0  0  0  0  0  0  0  0  0  0  0
+   -2.9346    2.1021   -0.8849 H   0  0  0  0  0  0  0  0  0  0  0  0
+  1  9  2  0  0  0  0
+  2 10  2  0  0  0  0
+  3  8  1  0  0  0  0
+  3 10  1  0  0  0  0
+  3 12  1  0  0  0  0
+  4  7  1  0  0  0  0
+  4 11  1  0  0  0  0
+  4 13  1  0  0  0  0
+  5  9  1  0  0  0  0
+  5 10  1  0  0  0  0
+  5 14  1  0  0  0  0
+  6  8  1  0  0  0  0
+  6 11  2  0  0  0  0
+  7  8  2  0  0  0  0
+  7  9  1  0  0  0  0
+ 11 15  1  0  0  0  0
+ 12 16  1  0  0  0  0
+ 12 17  1  0  0  0  0
+ 12 18  1  0  0  0  0
+ 13 19  1  0  0  0  0
+ 13 20  1  0  0  0  0
+ 13 21  1  0  0  0  0
+ 14 22  1  0  0  0  0
+ 14 23  1  0  0  0  0
+ 14 24  1  0  0  0  0
+M  END
+> <PUBCHEM_COMPOUND_CID>
+2519
+
+> <PUBCHEM_CONFORMER_RMSD>
+0.4
+
+> <PUBCHEM_CONFORMER_DIVERSEORDER>
+1
+
+> <PUBCHEM_MMFF94_PARTIAL_CHARGES>
+15
+1 -0.57
+10 0.69
+11 0.04
+12 0.3
+13 0.26
+14 0.3
+15 0.15
+2 -0.57
+3 -0.42
+4 0.05
+5 -0.42
+6 -0.57
+7 -0.24
+8 0.29
+9 0.71
+
+> <PUBCHEM_EFFECTIVE_ROTOR_COUNT>
+0
+
+> <PUBCHEM_PHARMACOPHORE_FEATURES>
+5
+1 1 acceptor
+1 2 acceptor
+3 4 6 11 cation
+5 4 6 7 8 11 rings
+6 3 5 7 8 9 10 rings
+
+> <PUBCHEM_HEAVY_ATOM_COUNT>
+14
+
+> <PUBCHEM_ATOM_DEF_STEREO_COUNT>
+0
+
+> <PUBCHEM_ATOM_UDEF_STEREO_COUNT>
+0
+
+> <PUBCHEM_BOND_DEF_STEREO_COUNT>
+0
+
+> <PUBCHEM_BOND_UDEF_STEREO_COUNT>
+0
+
+> <PUBCHEM_ISOTOPIC_ATOM_COUNT>
+0
+
+> <PUBCHEM_COMPONENT_COUNT>
+1
+
+> <PUBCHEM_CACTVS_TAUTO_COUNT>
+1
+
+> <PUBCHEM_CONFORMER_ID>
+000009D700000001
+
+> <PUBCHEM_MMFF94_ENERGY>
+22.901
+
+> <PUBCHEM_FEATURE_SELFOVERLAP>
+25.487
+
+> <PUBCHEM_SHAPE_FINGERPRINT>
+10967382 1 18338799025773621285
+11132069 177 18339075025094499008
+12524768 44 18342463625094026902
+13140716 1 17978511158789908153
+16945 1 18338517550775811621
+193761 8 15816500986559935910
+20588541 1 18339082691204868851
+21501502 16 18338796715286957384
+22802520 49 18128840606503503494
+2334 1 18338516344016692929
+23402539 116 18270382932679789735
+23552423 10 18262240993325675966
+23559900 14 18199193898169584358
+241688 4 18266458702623303353
+2748010 2 18266180539182415717
+5084963 1 17698433339235542986
+528886 8 18267580380709240570
+53812653 166 18198902694142226312
+66348 1 18339079396917369615
+
+> <PUBCHEM_SHAPE_MULTIPOLES>
+256.45
+4.01
+2.83
+0.58
+0.71
+0.08
+0
+-0.48
+0
+-0.81
+0
+0.01
+0
+0
+
+> <PUBCHEM_SHAPE_SELFOVERLAP>
+550.88
+
+> <PUBCHEM_SHAPE_VOLUME>
+143.9
+
+> <PUBCHEM_COORDINATE_TYPE>
+2
+5
+10
+
+$$$$
+"""
+
+CAFFEINE_3D_COORDS = (
+    np.array(
+        [
+            (0.4700, 2.5688, 0.0006),
+            (-3.1271, -0.4436, -0.0003),
+            (-0.9686, -1.3125, 0.0000),
+            (2.2182, 0.1412, -0.0003),
+            (-1.3477, 1.0797, -0.0001),
+            (1.4119, -1.9372, 0.0002),
+            (0.8579, 0.2592, -0.0008),
+            (0.3897, -1.0264, -0.0004),
+            (0.0307, 1.4220, -0.0006),
+            (-1.9061, -0.2495, -0.0004),
+            (2.5032, -1.1998, 0.0003),
+            (-1.4276, -2.6960, 0.0008),
+            (3.1926, 1.2061, 0.0003),
+            (-2.2969, 2.1881, 0.0007),
+            (3.5163, -1.5787, 0.0008),
+            (-1.0451, -3.1973, -0.8937),
+            (-2.5186, -2.7596, 0.0011),
+            (-1.0447, -3.1963, 0.8957),
+            (4.1992, 0.7801, 0.0002),
+            (3.0468, 1.8092, -0.8992),
+            (3.0466, 1.8083, 0.9004),
+            (-1.8087, 3.1651, -0.0003),
+            (-2.9322, 2.1027, 0.8881),
+            (-2.9346, 2.1021, -0.8849),
+        ],
+        np.double,
+    )
+    * unit.angstrom
+)
+
+# ========================================================
+# Various records for aspirin
+# ========================================================
+
+
+# From https://www.ebi.ac.uk/chembl/compound_report_card/CHEMBL25/
+ASPIRIN_2D_SDF = """\
+aspirin
+     RDKit          2D
+
+ 13 13  0  0  0  0  0  0  0  0999 V2000
+    8.8810   -2.1206    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+    8.8798   -2.9479    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+    9.5946   -3.3607    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+   10.3110   -2.9474    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+   10.3081   -2.1170    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+    9.5928   -1.7078    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+   11.0210   -1.7018    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+   11.7369   -2.1116    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
+   11.0260   -3.3588    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
+   11.0273   -4.1837    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+   11.7423   -4.5949    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+   10.3136   -4.5972    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
+   11.0178   -0.8769    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
+  1  2  2  0
+  5  7  1  0
+  3  4  2  0
+  7  8  2  0
+  4  9  1  0
+  4  5  1  0
+  9 10  1  0
+  2  3  1  0
+ 10 11  1  0
+  5  6  2  0
+ 10 12  2  0
+  6  1  1  0
+  7 13  1  0
+M  END
+
+> <chembl_id>
+CHEMBL25
+
+> <chembl_pref_name>
+ASPIRIN
+
+$$$$
+"""
+
+# From https://www.ebi.ac.uk/chembl/compound_report_card/CHEMBL25/
+ASPIRIN_SMI = "CC(=O)Oc1ccccc1C(=O)O ASPIRIN\n"
+
+# From https://pubchem.ncbi.nlm.nih.gov/compound/2244
+ASPIRIN_3D_SDF = """\
+2244
+  -OEChem-07012107583D
+
+ 21 21  0     0  0  0  0  0  0999 V2000
+    1.2333    0.5540    0.7792 O   0  0  0  0  0  0  0  0  0  0  0  0
+   -0.6952   -2.7148   -0.7502 O   0  0  0  0  0  0  0  0  0  0  0  0
+    0.7958   -2.1843    0.8685 O   0  0  0  0  0  0  0  0  0  0  0  0
+    1.7813    0.8105   -1.4821 O   0  0  0  0  0  0  0  0  0  0  0  0
+   -0.0857    0.6088    0.4403 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -0.7927   -0.5515    0.1244 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -0.7288    1.8464    0.4133 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -2.1426   -0.4741   -0.2184 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -2.0787    1.9238    0.0706 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -2.7855    0.7636   -0.2453 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -0.1409   -1.8536    0.1477 C   0  0  0  0  0  0  0  0  0  0  0  0
+    2.1094    0.6715   -0.3113 C   0  0  0  0  0  0  0  0  0  0  0  0
+    3.5305    0.5996    0.1635 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -0.1851    2.7545    0.6593 H   0  0  0  0  0  0  0  0  0  0  0  0
+   -2.7247   -1.3605   -0.4564 H   0  0  0  0  0  0  0  0  0  0  0  0
+   -2.5797    2.8872    0.0506 H   0  0  0  0  0  0  0  0  0  0  0  0
+   -3.8374    0.8238   -0.5090 H   0  0  0  0  0  0  0  0  0  0  0  0
+    3.7290    1.4184    0.8593 H   0  0  0  0  0  0  0  0  0  0  0  0
+    4.2045    0.6969   -0.6924 H   0  0  0  0  0  0  0  0  0  0  0  0
+    3.7105   -0.3659    0.6426 H   0  0  0  0  0  0  0  0  0  0  0  0
+   -0.2555   -3.5916   -0.7337 H   0  0  0  0  0  0  0  0  0  0  0  0
+  1  5  1  0  0  0  0
+  1 12  1  0  0  0  0
+  2 11  1  0  0  0  0
+  2 21  1  0  0  0  0
+  3 11  2  0  0  0  0
+  4 12  2  0  0  0  0
+  5  6  1  0  0  0  0
+  5  7  2  0  0  0  0
+  6  8  2  0  0  0  0
+  6 11  1  0  0  0  0
+  7  9  1  0  0  0  0
+  7 14  1  0  0  0  0
+  8 10  1  0  0  0  0
+  8 15  1  0  0  0  0
+  9 10  2  0  0  0  0
+  9 16  1  0  0  0  0
+ 10 17  1  0  0  0  0
+ 12 13  1  0  0  0  0
+ 13 18  1  0  0  0  0
+ 13 19  1  0  0  0  0
+ 13 20  1  0  0  0  0
+M  END
+> <PUBCHEM_COMPOUND_CID>
+2244
+
+> <PUBCHEM_CONFORMER_RMSD>
+0.6
+
+> <PUBCHEM_CONFORMER_DIVERSEORDER>
+1
+11
+10
+3
+15
+17
+13
+5
+16
+7
+14
+9
+8
+4
+18
+6
+12
+2
+
+> <PUBCHEM_MMFF94_PARTIAL_CHARGES>
+18
+1 -0.23
+10 -0.15
+11 0.63
+12 0.66
+13 0.06
+14 0.15
+15 0.15
+16 0.15
+17 0.15
+2 -0.65
+21 0.5
+3 -0.57
+4 -0.57
+5 0.08
+6 0.09
+7 -0.15
+8 -0.15
+9 -0.15
+
+> <PUBCHEM_EFFECTIVE_ROTOR_COUNT>
+3
+
+> <PUBCHEM_PHARMACOPHORE_FEATURES>
+5
+1 2 acceptor
+1 3 acceptor
+1 4 acceptor
+3 2 3 11 anion
+6 5 6 7 8 9 10 rings
+
+> <PUBCHEM_HEAVY_ATOM_COUNT>
+13
+
+> <PUBCHEM_ATOM_DEF_STEREO_COUNT>
+0
+
+> <PUBCHEM_ATOM_UDEF_STEREO_COUNT>
+0
+
+> <PUBCHEM_BOND_DEF_STEREO_COUNT>
+0
+
+> <PUBCHEM_BOND_UDEF_STEREO_COUNT>
+0
+
+> <PUBCHEM_ISOTOPIC_ATOM_COUNT>
+0
+
+> <PUBCHEM_COMPONENT_COUNT>
+1
+
+> <PUBCHEM_CACTVS_TAUTO_COUNT>
+1
+
+> <PUBCHEM_CONFORMER_ID>
+000008C400000001
+
+> <PUBCHEM_MMFF94_ENERGY>
+39.5952
+
+> <PUBCHEM_FEATURE_SELFOVERLAP>
+25.432
+
+> <PUBCHEM_SHAPE_FINGERPRINT>
+1 1 18265615372930943622
+100427 49 16967750034970055351
+12138202 97 18271247217817981012
+12423570 1 16692715976000295083
+12524768 44 16753525617747228747
+12716758 59 18341332292274886536
+13024252 1 17968377969333732145
+14181834 199 17830728755827362645
+14614273 12 18262232214645093005
+15207287 21 17703787037639964108
+15775835 57 18340488876329928641
+16945 1 18271533103414939405
+193761 8 17907860604865584321
+20645476 183 17677348215414174190
+20871998 184 18198632231250704846
+21040471 1 18411412921197846465
+21501502 16 18123463883164380929
+23402539 116 18271795865171824860
+23419403 2 13539898140662769886
+23552423 10 18048876295495619569
+23559900 14 18272369794190581304
+241688 4 16179044415907240795
+257057 1 17478316999871287486
+2748010 2 18339085878070479087
+305870 269 18263645056784260212
+528862 383 18117272558388284091
+53812653 8 18410289211719108569
+7364860 26 17910392788380644719
+81228 2 18050568744116491203
+
+> <PUBCHEM_SHAPE_MULTIPOLES>
+244.06
+3.86
+2.45
+0.89
+1.95
+1.58
+0.15
+-1.85
+0.38
+-0.61
+-0.02
+0.29
+0.01
+-0.33
+
+> <PUBCHEM_SHAPE_SELFOVERLAP>
+513.037
+
+> <PUBCHEM_SHAPE_VOLUME>
+136
+
+> <PUBCHEM_COORDINATE_TYPE>
+2
+5
+10
+
+$$$$
+"""
+
+
+# ========================================================
+# CHEBI:1148 triggers 'allow_undefined_stereo' exceptions
+# ========================================================
+
+CHEBI_1148_SDF = """\
+CHEBI:1148
+  Marvin  09120817212D          
+
+  7  6  0  0  0  0            999 V2000
+    1.4289   -0.1650    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
+    0.7145    0.2475    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+    0.0000   -0.1650    0.0000 C   0  0  3  0  0  0  0  0  0  0  0  0
+   -0.7145    0.2475    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+   -1.4289   -0.1650    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
+    0.7145    1.0725    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
+    0.0000   -0.9900    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
+  2  1  1  0  0  0  0
+  6  2  2  0  0  0  0
+  3  2  1  0  0  0  0
+  7  3  1  0  0  0  0
+  4  3  1  0  0  0  0
+  5  4  1  0  0  0  0
+M  END
+> <ChEBI ID>
+CHEBI:1148
+
+> <ChEBI Name>
+2-hydroxybutyric acid
+
+> <Star>
+3
+
+$$$$
+"""
+
+TWO_MOLS_SDF = CAFFEINE_2D_SDF + ASPIRIN_2D_SDF
+TWO_MOLS_SMI = CAFFEINE_SMI + ASPIRIN_SMI
+
+# Force invalid records to ensure the invalid one is skipped
+THREE_MOLS_SDF = (
+    CAFFEINE_2D_SDF + CAFFEINE_3D_SDF.replace("24 25", "20 29") + ASPIRIN_2D_SDF
+)
+THREE_MOLS_SMI = CAFFEINE_SMI + "Q" + CAFFEINE_SMI + ASPIRIN_SMI
+
+# ========================================================
+# Used to test that _cls is passed correctly
+# ========================================================
+
+
+class SingingMolecule(Molecule):
+    def sing(self):
+        return "The hills are alive with sound of music!"
+
+
+# ========================================================
+# Manage the input files
+# ========================================================
+
+
+# This code manages a temporary directory.
+# Data files can be found inside that directory.
+# They are created when needed, and stored for later use.
+
+# These descriptors make it possible to ask for
+# `file_manager.name_sdf' and get the path to the named file inside
+# the directory.
+
+
+class FilenameDescriptor:
+    def __init__(self, content):
+        self.content = content
+
+    def __set_name__(self, owner, name):
+        # Figure out the descriptor name, and
+        # the variable name used to store the filename.
+        self.private_name = f"_{name}_filename"
+        basename, _, ext = name.rpartition("_")
+        self.filename = f"{basename}.{ext}"
+
+    def __get__(self, obj, objtype=None):
+        # Have we been called before?
+        filename = getattr(obj, self.private_name, None)
+        if filename is None:
+            # No.
+            # Write the content to the named file in the manager's directory
+            file_path = obj.dir_path / self.filename
+            file_path.write_text(self.content)
+            # Save the filename to obj's private_name.
+            filename = str(file_path)
+            setattr(obj, self.private_name, filename)
+
+        # Return the path as a string
+        return filename
+
+
+class FileManager:
+    def __init__(self, temporary_directory):
+        # Use Python's object lifetime to manage directory cleanup
+        self.temporary_directory = temporary_directory
+
+        self.dir_path = pathlib.Path(temporary_directory.name)
+
+    caffeine_2d_sdf = FilenameDescriptor(CAFFEINE_2D_SDF)
+    caffeine_3d_sdf = FilenameDescriptor(CAFFEINE_3D_SDF)
+    caffeine_smi = FilenameDescriptor(CAFFEINE_SMI)
+
+    # Used to test that file_format overrides any automatic file detection.
+    caffeine_not_smi = FilenameDescriptor(CAFFEINE_2D_SDF)
+    caffeine_not_sdf = FilenameDescriptor(CAFFEINE_SMI)
+
+    ## aspirin_2d_sdf = FilenameDescriptor(ASPIRIN_2D_SDF)
+    ## aspirin_3d_sdf = FilenameDescriptor(ASPIRIN_3D_SDF)
+    ## aspirin_smi = FilenameDescriptor(ASPIRIN_SMI)
+
+    two_mols_sdf = FilenameDescriptor(TWO_MOLS_SDF)
+    two_mols_smi = FilenameDescriptor(TWO_MOLS_SMI)
+
+    three_mols_sdf = FilenameDescriptor(THREE_MOLS_SDF)
+    three_mols_smi = FilenameDescriptor(THREE_MOLS_SMI)
+
+    chebi_1148_sdf = FilenameDescriptor(CHEBI_1148_SDF)
+
+
+file_manager = FileManager(tempfile.TemporaryDirectory())
+
+
+# This is similar to the FilenameDescriptor excpet that it returns a
+# BytesIO.
+
+
+class FileobjDescriptor:
+    def __init__(self, content):
+        self.content = content.encode("utf8")
+
+    def __get__(self, obj, objtype=None):
+        # Have we been called before?
+        return BytesIO(self.content)
+
+
+class FileObjManager:
+    caffeine_2d_sdf = FileobjDescriptor(CAFFEINE_2D_SDF)
+    caffeine_3d_sdf = FileobjDescriptor(CAFFEINE_3D_SDF)
+    caffeine_smi = FileobjDescriptor(CAFFEINE_SMI)
+
+    ## aspirin_2d_sdf = FileobjDescriptor(ASPIRIN_2D_SDF)
+    ## aspirin_3d_sdf = FileobjDescriptor(ASPIRIN_3D_SDF)
+    ## aspirin_smi = FileobjDescriptor(ASPIRIN_SMI)
+
+    two_mols_sdf = FileobjDescriptor(TWO_MOLS_SDF)
+    two_mols_smi = FileobjDescriptor(TWO_MOLS_SMI)
+
+    three_mols_sdf = FileobjDescriptor(THREE_MOLS_SDF)
+    three_mols_smi = FileobjDescriptor(THREE_MOLS_SMI)
+
+    chebi_1148_sdf = FileobjDescriptor(CHEBI_1148_SDF)
+
+
+file_obj_manager = FileObjManager()
+
+# ========================================================
+# Base class to test from_file() and from_file_obj()
+# ========================================================
+
+
+class BaseFromFileIO:
+    # == Test variations of "sdf" and "mol"
+
+    @pytest.mark.parametrize("file_format", ("SDF", "sdf", "sdF", "MOL", "mol"))
+    def test_from_file_sdf_ignores_file_format_case(self, file_format):
+        mols = self.toolkit_wrapper.from_file(file_manager.caffeine_2d_sdf, file_format)
+        self._test_from_sdf_ignores_file_format_case(mols)
+
+    @pytest.mark.parametrize("file_format", ("SDF", "sdf", "sdF", "MOL", "mol"))
+    def test_from_file_obj_sdf_ignores_file_format_case(self, file_format):
+        mols = self.toolkit_wrapper.from_file_obj(
+            file_obj_manager.caffeine_2d_sdf, file_format
+        )
+        self._test_from_sdf_ignores_file_format_case(mols)
+
+    def _test_from_sdf_ignores_file_format_case(self, mols):
+        assert len(mols) == 1
+        mol = mols[0]
+        assert mol.name == "caffeine"
+
+    # == Test reading two molecules from an SDF
+
+    def test_from_file_sdf_two_molecules(self):
+        mols = self.toolkit_wrapper.from_file(file_manager.two_mols_sdf, "SDF")
+        self._test_from_sdf_two_molecules(mols)
+
+    def test_from_file_obj_sdf_two_molecules(self):
+        mols = self.toolkit_wrapper.from_file_obj(file_obj_manager.two_mols_sdf, "SDF")
+        self._test_from_sdf_two_molecules(mols)
+
+    def _test_from_sdf_two_molecules(self, mols):
+        assert len(mols) == 2
+        assert mols[0].name == "caffeine"
+        assert mols[1].name == "aspirin"
+
+    # == Test skipping an error molecule from an SDF
+
+    def test_from_file_sdf_three_molecules(self):
+        mols = self.toolkit_wrapper.from_file(file_manager.three_mols_sdf, "SDF")
+        self._test_from_sdf_two_molecules(mols)
+
+    def test_from_file_obj_sdf_three_molecules(self):
+        mols = self.toolkit_wrapper.from_file_obj(
+            file_obj_manager.three_mols_sdf, "SDF"
+        )
+        self._test_from_sdf_two_molecules(mols)
+
+    # == Test variations of "smi" format
+
+    @pytest.mark.parametrize("file_format", ("SMI", "smi", "sMi"))
+    def test_from_file_smi_ignores_file_format_case(self, file_format):
+        mols = self.toolkit_wrapper.from_file(file_manager.caffeine_smi, file_format)
+        self._test_from_smi_ignores_file_format_case(mols)
+
+    @pytest.mark.parametrize("file_format", ("SMI", "smi", "sMi"))
+    def test_from_fileobj_smi_ignores_file_format_case(self, file_format):
+        mols = self.toolkit_wrapper.from_file_obj(
+            file_obj_manager.caffeine_smi, file_format
+        )
+        self._test_from_smi_ignores_file_format_case(mols)
+
+    def _test_from_smi_ignores_file_format_case(self, mols):
+        assert len(mols) == 1
+        mol = mols[0]
+        assert mol.name == "CHEMBL113"
+        assert mol.n_atoms == 24
+        assert mol.n_bonds == 25
+        assert mol.n_conformers == 0
+
+    # == Test reading two molecules from a SMILES file
+
+    def test_from_file_smi_two_molecules(self):
+        mols = self.toolkit_wrapper.from_file(file_manager.two_mols_smi, "SMI")
+        self._test_from_smi_two_molecules(mols)
+
+    def test_from_file_obj_smi_two_molecules(self):
+        mols = self.toolkit_wrapper.from_file_obj(file_obj_manager.two_mols_smi, "SMI")
+        self._test_from_smi_two_molecules(mols)
+
+    def _test_from_smi_two_molecules(self, mols):
+        assert len(mols) == 2
+        assert mols[0].name == "CHEMBL113"
+        assert mols[1].name == "ASPIRIN"
+
+    # == Test skipping an error molecule from a SMI
+
+    def test_from_file_smi_three_molecules(self):
+        mols = self.toolkit_wrapper.from_file(file_manager.three_mols_smi, "SMI")
+        self._test_from_smi_two_molecules(mols)
+
+    def test_from_file_obj_smi_three_molecules(self):
+        mols = self.toolkit_wrapper.from_file_obj(
+            file_obj_manager.three_mols_smi, "SMI"
+        )
+        self._test_from_smi_two_molecules(mols)
+
+    # == Test that file_format overrides the extension
+
+    def test_from_file_sdf_ignores_filename_extension(self):
+        mols = self.toolkit_wrapper.from_file(file_manager.caffeine_not_smi, "sdf")
+        self._test_from_sdf_ignores_file_format_case(mols)
+
+    def test_from_file_smi_ignores_filename_extension(self):
+        mols = self.toolkit_wrapper.from_file(file_manager.caffeine_not_sdf, "smi")
+        self._test_from_smi_ignores_file_format_case(mols)
+
+    # == Test format "qwe" raises an exception
+
+    def test_from_file_qwe_format_raises_exception(self):
+        with pytest.raises(ValueError, match="Unsupported file format: QWE"):
+            mols = self.toolkit_wrapper.from_file(
+                file_manager.caffeine_2d_sdf, file_format="qwe"
+            )
+
+    def test_from_file_obj_qwe_format_raises_exception(self):
+        with pytest.raises(ValueError, match="Unsupported file format: QWE"):
+            mols = self.toolkit_wrapper.from_file_obj(
+                file_obj_manager.caffeine_2d_sdf, file_format="qwe"
+            )
+
+    # == Test when a file doesn't exist
+
+    @pytest.mark.parametrize("file_format", ["smi", "sdf", "mol"])
+    def test_from_file_when_the_filename_does_not_exist(self, file_format):
+        filename = f"/qwelkjpath/to/file/that/does/not/exist/asdflkjasdf.{file_format}"
+        with pytest.raises(OSError):
+            self.toolkit_wrapper.from_file(filename, file_format=file_format)
+
+    # == Test reading SDF 2D coordinates
+
+    def test_from_file_2D_sdf_coords(self):
+        mol = self.toolkit_wrapper.from_file(file_manager.caffeine_2d_sdf, "sdf")[0]
+        self._test_2D_sdf_coords(mol)
+
+    def test_from_file_obj_2D_sdf_coords(self):
+        mol = self.toolkit_wrapper.from_file_obj(
+            file_obj_manager.caffeine_2d_sdf, "sdf"
+        )[0]
+        self._test_2D_sdf_coords(mol)
+
+    def _test_2D_sdf_coords(self, mol):
+        assert mol.n_atoms == 24
+        assert mol.n_bonds == 25
+        assert mol.n_conformers == 1
+        conformer = mol.conformers[0]
+        assert conformer.shape == (24, 3)
+        assert conformer.unit == unit.angstrom
+
+        # Beyond this are the hydrogens, which are added by algorithm.
+        assert_allclose(conformer[: CAFFEINE_2D_COORDS.shape[0]], CAFFEINE_2D_COORDS)
+
+    # == Test reading SDF 3D coordinates
+
+    def test_from_file_3D_sdf_keeps_hydrogens(self):
+        mol = self.toolkit_wrapper.from_file(file_manager.caffeine_3d_sdf, "sdf")[0]
+        self._test_3D_sdf_keeps_hydrogens(mol)
+
+    def test_from_file_obj_3D_sdf_keeps_hydrogens(self):
+        mol = self.toolkit_wrapper.from_file_obj(
+            file_obj_manager.caffeine_3d_sdf, "sdf"
+        )[0]
+        self._test_3D_sdf_keeps_hydrogens(mol)
+
+    def _test_3D_sdf_keeps_hydrogens(self, mol):
+        assert mol.n_atoms == 24
+        assert mol.n_bonds == 25
+        assert mol.n_conformers == 1
+        conformer = mol.conformers[0]
+        assert conformer.shape == (24, 3)
+        assert conformer.unit == unit.angstrom
+
+        # All hydrogens are explicit in the file
+        assert_allclose(conformer, CAFFEINE_3D_COORDS)
+
+    # == Test allow_undefined_stereo
+
+    def test_from_file_with_undefined_stereo(self):
+        with pytest.raises(
+            exceptions.UndefinedStereochemistryError,
+            match=f"Unable to make OFFMol from {self.tk_mol_name}: {self.tk_mol_name} has unspecified stereochemistry",
+        ):
+            self.toolkit_wrapper.from_file(file_manager.chebi_1148_sdf, "sdf")
+
+    def test_from_file_obj_with_undefined_stereo(self):
+        with pytest.raises(
+            exceptions.UndefinedStereochemistryError,
+            match=f"Unable to make OFFMol from {self.tk_mol_name}: {self.tk_mol_name} has unspecified stereochemistry",
+        ):
+            self.toolkit_wrapper.from_file_obj(file_obj_manager.chebi_1148_sdf, "sdf")
+
+    def test_from_file_with_undefined_stereo_allowed(self):
+        self.toolkit_wrapper.from_file(
+            file_manager.chebi_1148_sdf, "sdf", allow_undefined_stereo=True
+        )[0]
+
+    def test_from_file_obj_with_undefined_stereo_allowed(self):
+        self.toolkit_wrapper.from_file_obj(
+            file_obj_manager.chebi_1148_sdf, "sdf", allow_undefined_stereo=True
+        )[0]
+
+    # == Test passing in a user-defined _cls
+
+    @pytest.mark.parametrize(
+        "name,file_format", [("caffeine_2d_sdf", "SDF"), ("caffeine_smi", "SMI")]
+    )
+    def test_from_file_handles_cls(self, name, file_format):
+        filename = getattr(file_manager, name)
+        mol = self.toolkit_wrapper.from_file(
+            filename, file_format, _cls=SingingMolecule
+        )[0]
+        mol.sing()
+
+    @pytest.mark.parametrize(
+        "name,file_format", [("caffeine_2d_sdf", "SDF"), ("caffeine_smi", "SMI")]
+    )
+    def test_from_file_obj_handles_cls(self, name, file_format):
+        file_obj = getattr(file_obj_manager, name)
+        mol = self.toolkit_wrapper.from_file_obj(
+            file_obj, file_format, _cls=SingingMolecule
+        )[0]
+        mol.sing()
+
+
+# Create the appropriate toolkit wrapper for each test class instance.
+#
+# We could create them at the module level, but this would require
+# re-doing the license checks that @requires_openeye and
+# @requires_rdkit already do. Instead, let those decorators handle the
+# license check, and use pytest's fixture system to create the
+# instance given the appropriate wrapper class defined in the
+# subclass.
+#
+# The wrappers are stateless, so specify it as class fixture, instead
+# of creating a new instance for each test
+
+
+@pytest.fixture(scope="class")
+def init_toolkit(request):
+    request.cls.toolkit_wrapper = request.cls.toolkit_wrapper_class()
+
+
+@pytest.mark.usefixtures("init_toolkit")
+@requires_openeye
+class TestOpenEyeToolkitFromFileIO(BaseFromFileIO):
+    toolkit_wrapper_class = OpenEyeToolkitWrapper
+    tk_mol_name = "OEMol"
+
+
+@pytest.mark.usefixtures("init_toolkit")
+@requires_rdkit
+class TestRDKitToolkitFromFileIO(BaseFromFileIO):
+    toolkit_wrapper_class = RDKitToolkitWrapper
+    tk_mol_name = "RDMol"
+
+    def test_from_file_obj_smi_supports_stringio(self):
+        # Test the backwards compatibility support for
+        # passing in file objects open in "t"ext mode.
+        with open(file_manager.caffeine_smi) as file_obj:
+            mol = self.toolkit_wrapper.from_file_obj(file_obj, "SMI")[0]
+        assert mol.name == "CHEMBL113"
+
+
+# ========================================================
+# Base class to test to_file() and to_file_obj()
+# ========================================================
+
+
+@pytest.fixture(scope="class")
+def tmpdir(request):
+    request.cls.tmpdir = tmpdir = tempfile.TemporaryDirectory()
+    with tmpdir:
+        yield
+    request.cls.tmpdir = None
+
+
+def assert_is_ethanol_sdf(f):
+    assert f.readline() == "ethanol\n"  # title line
+    f.readline()  # ignore next two lines
+    f.readline()
+    assert f.readline()[:6] == "  9  8"  # check 9 atoms, 8 bonds
+
+
+def assert_is_ethanol_smi(f):
+    line = f.readline()
+    assert "ethanol" in line
+    assert_is_ethanol_smiles(line.split()[0])
+    assert not f.readline(), "should only have one line"
+
+
+def assert_is_ethanol_smiles(smiles):
+    assert smiles.count("[H]") == 6
+    assert smiles.count("O") == 1
+    assert smiles.count("C") == 2
+
+
+class BaseToFileIO:
+    def get_tmpfile(self, name):
+        return os.path.join(self.tmpdir.name, name)
+
+    # == Test variations of "sdf" and "mol"
+
+    @pytest.mark.parametrize("format_name", ["SDF", "sdf", "sDf", "mol", "MOL"])
+    def test_to_file_sdf(self, format_name):
+        filename = self.get_tmpfile("abc.xyz")
+        self.toolkit_wrapper.to_file(ETHANOL, filename, format_name)
+        with open(filename) as f:
+            assert_is_ethanol_sdf(f)
+
+    @pytest.mark.parametrize("format_name", ["SDF", "sdf", "sDf", "mol", "MOL"])
+    def test_to_file_obj_sdf(self, format_name):
+        f = StringIO()
+        self.toolkit_wrapper.to_file_obj(ETHANOL, f, format_name)
+        f.seek(0)
+        assert_is_ethanol_sdf(f)
+
+    def test_to_file_obj_sdf_with_bytesio(self):
+        f = BytesIO()
+        with pytest.raises(
+            ValueError,
+            match="Need a text mode file object like StringIO or a file opened with mode 't'",
+        ):
+            self.toolkit_wrapper.to_file_obj(ETHANOL, f, "sdf")
+
+    # === Test variations of "smi"
+
+    @pytest.mark.parametrize("format_name", ["SMI", "smi", "sMi"])
+    def test_to_file_smi(self, format_name):
+        filename = self.get_tmpfile("abc.xyz")
+        self.toolkit_wrapper.to_file(ETHANOL, filename, format_name)
+        with open(filename) as f:
+            assert_is_ethanol_smi(f)
+
+    @pytest.mark.parametrize("format_name", ["SMI", "smi", "sMi"])
+    def test_to_file_obj_smi(self, format_name):
+        f = StringIO()
+        self.toolkit_wrapper.to_file_obj(ETHANOL, f, format_name)
+        f.seek(0)
+        assert_is_ethanol_smi(f)
+
+    def test_to_file_obj_smi_with_bytesio(self):
+        f = BytesIO()
+        with pytest.raises(
+            ValueError,
+            match="Need a text mode file object like StringIO or a file opened with mode 't'",
+        ):
+            self.toolkit_wrapper.to_file_obj(ETHANOL, f, "smi")
+
+    # === Test format "qwe" raises an exception
+
+    def test_to_file_qwe_format_raises_exception(self):
+        with tempfile.NamedTemporaryFile(suffix=".smi") as fileobj:
+            with pytest.raises(ValueError, match=f"Unsupported file format: QWE"):
+                self.toolkit_wrapper.to_file(ETHANOL, fileobj.name, "QWE")
+
+    # === Test writing to a file that does not exist
+
+    @pytest.mark.parametrize("format_name", ["smi", "sdf", "mol"])
+    def test_to_file_when_the_file_does_not_exist(self, format_name):
+        filename = self.get_tmpfile("does/not/exist.smi")
+        with pytest.raises(OSError):
+            self.toolkit_wrapper.to_file(ETHANOL, filename, format_name)
+
+
+@pytest.mark.usefixtures("init_toolkit", "tmpdir")
+@requires_openeye
+class TestOpenEyeToolkitToFileIO(BaseToFileIO):
+    toolkit_wrapper_class = OpenEyeToolkitWrapper
+
+
+@pytest.mark.usefixtures("init_toolkit", "tmpdir")
+@requires_rdkit
+class TestRDKitToolkitToFileIO(BaseToFileIO):
+    toolkit_wrapper_class = RDKitToolkitWrapper
+
+
+# ========================================================
+# Base class to test SMILES parsing
+# ========================================================
+
+
+class BaseSmiles:
+    def test_parse_methane_with_implicit_Hs(self):
+        mol = self.toolkit_wrapper.from_smiles("C")
+        # adds hydrogens
+        assert mol.n_atoms == 5
+        assert mol.n_bonds == 4
+        assert mol.partial_charges is None
+
+    def test_parse_methane_with_implicit_Hs_but_said_they_are_explicit(self):
+        with pytest.raises(
+            ValueError,
+            match=(
+                "'hydrogens_are_explicit' was specified as True, but (OpenEye|RDKit) [Tt]oolkit interpreted SMILES "
+                "[^ ]+ as having implicit hydrogen. If this SMILES is intended to express all explicit hydrogens "
+                f"in the molecule, then you should construct the desired molecule as an {self.tk_mol_name}"
+            ),
+        ):
+            mol = self.toolkit_wrapper.from_smiles("C", hydrogens_are_explicit=True)
+
+    def test_parse_methane_with_explicit_Hs(self):
+        mol = self.toolkit_wrapper.from_smiles("[C]([H])([H])([H])([H])")
+        # add hydrogens
+        assert mol.n_atoms == 5
+        assert mol.n_bonds == 4
+        assert mol.partial_charges is None
+
+    def test_parse_methane_with_explicit_Hs_and_say_they_are_explicit(self):
+        mol = self.toolkit_wrapper.from_smiles(
+            "[C]([H])([H])([H])([H])", hydrogens_are_explicit=True
+        )
+        # add hydrogens
+        assert mol.n_atoms == 5
+        assert mol.n_bonds == 4
+
+    def test_parse_bad_smiles(self):
+        with pytest.raises(ValueError, match="Unable to parse the SMILES string"):
+            mol = self.toolkit_wrapper.from_smiles("QWERT")
+
+    ### Copied from test_toolkits.py
+
+    @pytest.mark.parametrize(
+        "title, smiles",
+        [
+            ("unspec_chiral_smiles", r"C\C(F)=C(/F)CC(C)(Cl)Br"),
+            ("spec_chiral_smiles", r"C\C(F)=C(/F)C[C@@](C)(Cl)Br"),
+            ("unspec_db_smiles", r"CC(F)=C(F)C[C@@](C)(Cl)Br"),
+            ("spec_db_smiles", r"C\C(F)=C(/F)C[C@@](C)(Cl)Br"),
+        ],
+    )
+    def test_smiles_missing_stereochemistry(self, title, smiles):
+        if "unspec" in title:
+            with pytest.raises(exceptions.UndefinedStereochemistryError):
+                self.toolkit_wrapper.from_smiles(smiles)
+            mol = self.toolkit_wrapper.from_smiles(smiles, allow_undefined_stereo=True)
+        else:
+            mol = self.toolkit_wrapper.from_smiles(smiles)
+        assert mol.n_atoms == 18
+
+    @pytest.mark.parametrize(
+        "smiles, expected_map", [("[Cl:1][H]", {0: 1}), ("[Cl:1][H:2]", {0: 1, 1: 2})]
+    )
+    def test_from_smiles_with_atom_map(self, smiles, expected_map):
+        mol = self.toolkit_wrapper.from_smiles(smiles)
+        assert mol.properties["atom_map"] == expected_map
+
+    def test_ethanol_to_smiles(self):
+        smiles = self.toolkit_wrapper.to_smiles(ETHANOL)
+        assert_is_ethanol_smiles(smiles)
+
+    def test_round_trip_ethanol_to_smiles_adds_hydrogens(self):
+        mol = self.toolkit_wrapper.from_smiles("CCO")
+        smiles = self.toolkit_wrapper.to_smiles(mol)
+        assert_is_ethanol_smiles(smiles)
+
+
+@pytest.mark.usefixtures("init_toolkit")
+@requires_openeye
+class TestOpenEyeToolkitSmiles(BaseSmiles):
+    toolkit_wrapper_class = OpenEyeToolkitWrapper
+    tk_mol_name = "OEMol"
+
+
+@pytest.mark.usefixtures("init_toolkit")
+@requires_rdkit
+class TestRDKitToolkitSmiles(BaseSmiles):
+    toolkit_wrapper_class = RDKitToolkitWrapper
+    tk_mol_name = "RDMol"
+
+
+if __name__ == "__main__":
+    sys.exit(pytest.main([__file__] + sys.argv[1:]))