Repository: spotfiresoftware/spotfire-python (language: python)

Issue:
New version of pylint (2.16.2) identifies new issues
A version of `pylint` is available in the Python package repositories that identifies issues with the `spotfire` package.  Since we attempt to keep all pylint issues out of the source code (either by fixing the issue identified or by disabling that message with a localized comment), this is indicative of a new check in this new version of `pylint`.

Please investigate these issues, and either fix the source or disable the check with a comment.  Further checks by this automation will be held until this issue is closed.  Make sure that the fix updates the `pylint` requirement in `requirements_lint.txt` to the version identified here (2.16.2).

For reference, here is the output of this version of `pylint`:

```
$ pylint spotfire
************* Module spotfire.data_function
spotfire/data_function.py:216: [R1735, AnalyticSpec.__init__] Consider using '{"__builtins__": __builtins__}' instead of a call to 'dict'. (use-dict-literal)
************* Module spotfire.codesign
spotfire/codesign.py:198: [W0719, codesign_file] Raising too general exception: Exception (broad-exception-raised)
spotfire/codesign.py:241: [W0719, _open_certificate] Raising too general exception: Exception (broad-exception-raised)
spotfire/codesign.py:250: [W0719, _extract_certificate] Raising too general exception: Exception (broad-exception-raised)
spotfire/codesign.py:263: [W0719, _extract_certificate] Raising too general exception: Exception (broad-exception-raised)
spotfire/codesign.py:325: [W0719, _sign] Raising too general exception: Exception (broad-exception-raised)
spotfire/codesign.py:345: [W0719, _timestamp] Raising too general exception: Exception (broad-exception-raised)

-----------------------------------
Your code has been rated at 9.97/10


```

*This issue was automatically opened by the `pylint.yaml` workflow.*



Discussion / hints:

Fix patch:
diff --git a/.github/python-versions.json b/.github/python-versions.json
new file mode 100644
index 0000000..a1f0c00
--- /dev/null
+++ b/.github/python-versions.json
@@ -0,0 +1,1 @@
+[ "3.11", "3.10", "3.9", "3.8", "3.7" ]
diff --git a/.github/scripts/download_build.py b/.github/scripts/download_build.py
new file mode 100644
index 0000000..97047d7
--- /dev/null
+++ b/.github/scripts/download_build.py
@@ -0,0 +1,61 @@
+# pylint: skip-file
+
+import argparse
+import io
+import logging
+import os
+import sys
+import zipfile
+
+from ghapi.all import GhApi
+from ghapi.page import paged
+
+
+def find_workflows(api, owner, repo, path):
+    for wf in api.actions.list_repo_workflows(owner, repo).workflows:
+        logging.debug("workflow id %d, %s ('%s')", wf.id, wf.path, wf.name)
+        if wf.path == path:
+            logging.debug("That's the one!")
+            return wf.id
+    logging.error("Build workflow for file '%s' not found", path)
+    sys.exit(2)
+
+
+def find_runs(api, owner, repo, workflow_id, build_number):
+    for result in paged(api.actions.list_workflow_runs, owner, repo, workflow_id):
+        for run in result.workflow_runs:
+            logging.debug("run id %d #%d", run.id, run.run_number)
+            if run.run_number == build_number:
+                logging.debug("That's the one!")
+                return run.id
+    logging.error("Build workflow run not found")
+    sys.exit(3)
+
+
+def main():
+    logging.basicConfig(level=os.environ.get('LOGLEVEL', 'WARNING').upper())
+    # Process command line arguments
+    parser = argparse.ArgumentParser(description="Download all artifacts attached to a build.")
+    parser.add_argument("--build", type=int, required=True, help="Build number to download")
+    parser.add_argument("--dir", required=True, help="Directory to download artifacts to")
+    args = parser.parse_args()
+    # Connect to GitHub REST API
+    api = GhApi()
+    owner = "TIBCOSoftware"
+    repo = "spotfire-python"
+    # List workflows
+    workflow_id = find_workflows(api, owner, repo, '.github/workflows/build.yaml')
+    # Find the id for our run
+    run_id = find_runs(api, owner, repo, workflow_id, args.build)
+    # Now grab each artifact
+    result = api.actions.list_workflow_run_artifacts(owner, repo, run_id)
+    for artifact in result.artifacts:
+        logging.debug("artifact %d %s (%d bytes)", artifact.id, artifact.name, artifact.size_in_bytes)
+        download = api.actions.download_artifact(owner, repo, artifact.id, 'zip')
+        with zipfile.ZipFile(io.BytesIO(download)) as z:
+            z.extractall(args.dir)
+        logging.debug("artifact downloaded")
+
+
+if __name__ == '__main__':
+    main()
diff --git a/.github/scripts/parse_pyproject_toml.py b/.github/scripts/parse_pyproject_toml.py
new file mode 100644
index 0000000..8ccd778
--- /dev/null
+++ b/.github/scripts/parse_pyproject_toml.py
@@ -0,0 +1,45 @@
+# pylint: skip-file
+
+import argparse
+
+
+def output_string(value, as_string):
+    if as_string:
+        print(value.strip('"'))
+    else:
+        print(value)
+
+
+def output(value, as_string, break_array):
+    if break_array:
+        vals = value.strip('[]').split(",")
+        for v in vals:
+            output_string(v.strip(), as_string)
+    else:
+        output_string(value, as_string)
+
+
+def main():
+    # Process command line arguments
+    parser = argparse.ArgumentParser("Quickly parse the pyproject.toml file")
+    parser.add_argument("--section", help="The section to extract from")
+    parser.add_argument("--key", help="The key to extract")
+    parser.add_argument("--array", action="store_true", help="Break apart arrays")
+    parser.add_argument("--string", action="store_true", help="Print strings unquoted")
+    args = parser.parse_args()
+
+    # Now parse the file
+    with open("pyproject.toml", "r") as f:
+        for line in f.readlines():
+            if line.startswith('['):
+                in_section = line.strip().strip('[]')
+            else:
+                split = line.split('=', 1)
+                key = split[0].strip()
+                value = split[1].strip()
+                if in_section == args.section and key == args.key:
+                    output(value, args.string, args.array)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/.github/scripts/requirements_upload.txt b/.github/scripts/requirements_upload.txt
new file mode 100644
index 0000000..2e14f62
--- /dev/null
+++ b/.github/scripts/requirements_upload.txt
@@ -0,0 +1,2 @@
+PyGithub
+twine
\ No newline at end of file
diff --git a/.github/scripts/run_pylint_check.py b/.github/scripts/run_pylint_check.py
index f9b8e11..556fd63 100644
--- a/.github/scripts/run_pylint_check.py
+++ b/.github/scripts/run_pylint_check.py
@@ -1,3 +1,5 @@
+# pylint: skip-file
+
 import argparse
 import io
 import os
diff --git a/.github/scripts/upload.py b/.github/scripts/upload.py
new file mode 100644
index 0000000..c5a4280
--- /dev/null
+++ b/.github/scripts/upload.py
@@ -0,0 +1,80 @@
+import argparse
+import io
+import os
+import tempfile
+import urllib.request as rq
+import zipfile
+
+import twine.repository
+from github import Github
+from twine import repository, package
+
+
+def raw_download(requester, url):
+    status, headers, data = requester.requestBlob("GET", url)
+    if status == 302:
+        req = rq.Request(headers["location"])
+        with rq.urlopen(req) as resp:
+            status = resp.status
+            headers = dict(resp.headers.items())
+            data = resp.read()
+    return status, headers, data
+
+
+def main():
+    # Process command line arguments
+    parser = argparse.ArgumentParser("Perform release activities and upload artifacts to the right endpoints.")
+    parser.add_argument("--release", metavar="VERSION", help="The version number to release")
+    parser.add_argument("--pypi-token", metavar="TOKEN", help="The PyPI API token to use")
+    parser.add_argument("--gh-token", metavar="TOKEN", help="The GitHub API token to use")
+    parser.add_argument("--repo", help="The owner and repository we are operating on")
+    args = parser.parse_args()
+
+    # Connect to GitHub REST API
+    gh = Github(args.gh_token)
+    repo = gh.get_repo(args.repo)
+
+    # Download artifacts from current release branch build workflow
+    with tempfile.TemporaryDirectory() as tempdir:
+        branch = f"release/{args.release}"
+        print(f"Searching for workflow runs for '{branch}' branch")
+        workflow_run = repo.get_workflow_runs(branch=branch)[0]
+        print(f"Found run {workflow_run.id}")
+        for a in workflow_run.get_artifacts():
+            if a.name == "sdist" or a.name.startswith("wheel-"):
+                print(f"Downloading artifact '{a.name}' from {a.archive_download_url}")
+                _, _, a_data = raw_download(repo._requester, a.archive_download_url)
+                with zipfile.ZipFile(io.BytesIO(a_data)) as a_zip:
+                    for a_zip_entry in a_zip.namelist():
+                        print(f"Extracting {a_zip_entry}")
+                        a_zip.extract(a_zip_entry, tempdir)
+
+        # Create a new draft release
+        print(f"Creating draft GH release")
+        gh_release = repo.create_git_release(tag=f"v{args.release}", name=args.release, target_commitish=branch,
+                                             message="_Replace with release notes._",
+                                             draft=True, prerelease=False)
+
+        # Upload artifacts to GH draft release
+        for filename in os.scandir(tempdir):
+            print(f"Uploading {filename.path} to GH release")
+            gh_release.upload_asset(filename.path)
+
+        # Upload artifacts to PyPI
+        pypi_url = "https://upload.pypi.org/legacy/"
+        print(f"Uploading to PyPI at {pypi_url}")
+        pypi_repo = twine.repository.Repository(pypi_url, "__token__", args.pypi_token, disable_progress_bar=True)
+        pypi_pkgs = []
+        for filename in os.scandir(tempdir):
+            pypi_pkg = twine.package.PackageFile.from_filename(filename.path, None)
+            pypi_pkgs.append(pypi_pkg)
+            pypi_response = pypi_repo.upload(pypi_pkg)
+            if pypi_response.text:
+                print(f"Message from PyPI: {pypi_response.text}")
+        pypi_release = pypi_repo.release_urls(pypi_pkgs)
+        if pypi_release:
+            print(f"Release available on PyPI at {pypi_release}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index cfbe952..e0c1638 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -1,62 +1,151 @@
 name: Build and Test Package
 on: [push, pull_request]
 jobs:
-  build:
-    name: Packaging and Static Analysis
+  build-sdist:
+    name: Build Source Dist
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v3
+        with:
+          submodules: recursive
       - name: Set Up Python
         uses: actions/setup-python@v4
         with:
           python-version: '3.x'
       - name: Install Tools
         run: |
-          python -m pip install --upgrade pip
-          pip install -r requirements_lint.txt
-      - name: Static Analysis with pylint
-        run: |
-          pylint spotfire
-      - name: Packaging
+          python .github/scripts/parse_pyproject_toml.py --section build-system --key requires --array --string > pyproj-requires.txt
+          pip install -r pyproj-requires.txt
+      - name: Source Packaging
         run: |
-          python setup.py sdist bdist_wheel
+          python setup.py sdist
       - uses: actions/upload-artifact@v3
         with:
-          name: dist
+          name: sdist
           path: 'dist/**'
-      - name: Determine Test Environments
-        id: find-test-envs
+      - uses: actions/upload-artifact@v3
+        with:
+          name: test-files
+          path: |
+            spotfire/test/files/**
+            spotfire/requirements.txt
+            test_requirements_*.txt
+      - uses: actions/upload-artifact@v3
+        with:
+          name: analysis-files
+          path: |
+            pylintrc
+            requirements_lint.txt
+      - name: Dynamic Elements
+        id: dynamic
         run: |
           echo -n "test-envs=[" >> $GITHUB_OUTPUT
           ls test_requirements_* | sed -e 's/test_requirements_/"/' -e 's/.txt/"/' | tr '\n' ',' | sed -e 's/,$//' >> $GITHUB_OUTPUT
           echo "]" >> $GITHUB_OUTPUT
+          echo -n "pythons=" >> $GITHUB_OUTPUT
+          cat .github/python-versions.json >> $GITHUB_OUTPUT
     outputs:
-      test-environments: ${{ steps.find-test-envs.outputs.test-envs }}
+      test-environments: ${{ steps.dynamic.outputs.test-envs }}
+      python-versions: ${{ steps.dynamic.outputs.pythons }}
+  build-wheel:
+    name: Build Wheels
+    needs: build-sdist
+    runs-on: ${{ matrix.operating-system }}
+    strategy:
+      matrix:
+        python-version: ${{ fromJson(needs.build-sdist.outputs.python-versions) }}
+        operating-system: ['ubuntu-latest', 'windows-latest']
+      fail-fast: false
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          submodules: recursive
+      - name: Set Up Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install Build Requirements
+        run: |
+          python .github/scripts/parse_pyproject_toml.py --section build-system --key requires --array --string > pyproj-requires.txt
+          pip install auditwheel -r pyproj-requires.txt
+      - name: Build Wheel
+        run: |
+          python setup.py bdist_wheel
+      - name: Convert Wheel to Manylinux
+        if: ${{ matrix.operating-system == 'ubuntu-latest' }}
+        run: |
+          mv dist/*.whl .
+          auditwheel repair -w dist --plat manylinux2014_x86_64 ./*.whl
+      - uses: actions/upload-artifact@v3
+        with:
+          name: wheel-${{ matrix.python-version }}-${{ matrix.operating-system }}
+          path: 'dist/**'
   test:
     name: Test
-    needs: build
-    runs-on: ubuntu-latest
+    needs: [build-sdist, build-wheel]
+    runs-on: ${{ matrix.operating-system }}
     strategy:
       matrix:
-        python-version: ['3.7', '3.8', '3.9', '3.10']
-        test-environment: ${{ fromJson(needs.build.outputs.test-environments) }}
+        python-version: ${{ fromJson(needs.build-sdist.outputs.python-versions) }}
+        operating-system: ['ubuntu-latest', 'windows-latest']
+        test-environment: ${{ fromJson(needs.build-sdist.outputs.test-environments) }}
       fail-fast: false
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/download-artifact@v3
+        with:
+          name: wheel-${{ matrix.python-version }}-${{ matrix.operating-system }}
+          path: dist
+      - uses: actions/download-artifact@v3
+        with:
+          name: test-files
+          path: test-files
       - name: Set Up Python
         uses: actions/setup-python@v4
         with:
           python-version: ${{ matrix.python-version }}
-      - name: Install Dependencies
+      - name: Install Dependencies (Linux)
+        if: matrix.operating-system == 'ubuntu-latest'
+        run: |
+          pip install dist/*.whl -r test-files/test_requirements_${{ matrix.test-environment }}.txt
+      - name: Install Dependencies (Windows)
+        if: matrix.operating-system == 'windows-latest'
         run: |
-          python -m pip install --upgrade pip
-          pip install -r test_requirements_${{ matrix.test-environment }}.txt
+          pip install $((dir dist\*.whl).FullName) -r test-files/test_requirements_${{ matrix.test-environment }}.txt
       - name: Run Tests
         run: |
           python -m spotfire.test
+        env:
+          RESULTS_NAME: ${{ matrix.python-version }}-${{ matrix.operating-system }}-${{ matrix.test-environment }}
+          TEST_FILES_DIR: ${{ github.workspace }}/test-files/spotfire/test/files
       - uses: actions/upload-artifact@v3
         if: ${{ always() }}
         with:
-          name: test-results-${{ matrix.python-version }}-${{ matrix.test-environment }}
+          name: test-results-${{ matrix.python-version }}-${{ matrix.operating-system }}-${{ matrix.test-environment }}
           path: 'results-*.xml'
-
+  lint:
+    name: Static Analysis
+    needs: [build-sdist, build-wheel]
+    runs-on: ubuntu-latest
+    steps:
+      - name: Process Python Version
+        id: version
+        run: |
+          echo -n "python-version="  >> $GITHUB_OUTPUT
+          echo '${{ needs.build-sdist.outputs.python-versions }}' | sed -e 's/[^"]*"//' -e 's/".*//' >> $GITHUB_OUTPUT
+      - name: Set Up Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ steps.version.outputs.python-version }}
+      - uses: actions/download-artifact@v3
+        with:
+          name: wheel-${{ steps.version.outputs.python-version }}-ubuntu-latest
+          path: dist
+      - uses: actions/download-artifact@v3
+        with:
+          name: analysis-files
+      - name: Install Tools
+        run: |
+          pip install dist/*.whl -r requirements_lint.txt
+      - name: Static Analysis with pylint
+        run: |
+          pylint spotfire
diff --git a/.gitignore b/.gitignore
index 44712e3..6a9945b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,21 @@
+# Cython generated sources
+/spotfire/*.[ch]
+!/spotfire/*_helpers.[ch]
+/spotfire/*.html
+/cython_debug/
+
 # byte-compiled files
 __pycache__/
-*.py[cod]
+*.py[co]
+
+# compiler output files
+*.pyd
+*.pdb
+*.so
+
+# unit test results files
+/results-*.xml
+/spotfire/test/output.sbdf
 
 # distribution
 /build/
@@ -8,6 +23,9 @@ __pycache__/
 /*.egg-info/
 /MANIFEST
 
+# virtual environments
+/venv/
+
 # PyCharm project files
 /.idea
 
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..f3390e6
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "vendor/sbdf-c"]
+	path = vendor/sbdf-c
+	url = https://github.com/TIBCOSoftware/spotfire-sbdf-c.git
diff --git a/MANIFEST.in b/MANIFEST.in
index 9ab343c..c1cfc70 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,12 @@
 include LICENSE
 include requirements_dev.txt
 include spotfire/requirements.txt
-exclude setup.cfg
\ No newline at end of file
+include spotfire/*.pyx
+recursive-exclude spotfire/test *
+exclude setup.cfg
+recursive-include vendor *.pxd *.h *.c
+recursive-exclude venv *
+recursive-exclude .github *
+exclude .gitattributes
+exclude .gitignore
+exclude .whitesource*
diff --git a/pylintrc b/pylintrc
index e3ca51d..0dcfc7d 100644
--- a/pylintrc
+++ b/pylintrc
@@ -3,7 +3,7 @@
 # A comma-separated list of package or module names from where C extensions may
 # be loaded. Extensions are loading into the active Python interpreter and may
 # run arbitrary code
-extension-pkg-whitelist=
+extension-pkg-allow-list=spotfire.sbdf,spotfire.codesign
 
 # Add files or directories to the blacklist. They should be base names, not
 # paths.
@@ -50,7 +50,7 @@ confidence=
 # --enable=similarities". If you want to run only the classes checker, but have
 # no Warning level messages displayed, use"--disable=all --enable=classes
 # --disable=W"
-disable=raw-checker-failed,bad-inline-option,locally-disabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,import-error
+disable=raw-checker-failed,bad-inline-option,locally-disabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,import-error,cyclic-import
 
 # Enable the message, report, category or checker with the given id(s). You can
 # either give multiple identifier separated by comma (,) or put this option
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..8ec6f5c
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,2 @@
+[build-system]
+requires = ["setuptools", "wheel", "Cython", "oldest-supported-numpy"]
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 3e191bb..ce37f27 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -5,4 +5,5 @@ geopandas
 matplotlib
 pillow
 seaborn
-shapely
\ No newline at end of file
+shapely
+Cython
diff --git a/requirements_lint.txt b/requirements_lint.txt
index a0dbfc3..8a096dd 100644
--- a/requirements_lint.txt
+++ b/requirements_lint.txt
@@ -1,2 +1,2 @@
 wheel
-pylint==2.15.5
+pylint==2.16.2
diff --git a/setup.cfg b/setup.cfg
index 3c6e79c..e69de29 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,2 +0,0 @@
-[bdist_wheel]
-universal=1
diff --git a/setup.py b/setup.py
index 76c1801..2c03b73 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,12 @@
 # in the license file that is distributed with this file.
 
 # pylint: skip-file
+import sys
+
+from distutils import log
 from setuptools import setup, find_packages
+from Cython.Distutils import Extension, build_ext
+import numpy as np
 
 
 def get_requires(filename):
@@ -15,6 +20,27 @@ def get_requires(filename):
     return requirements
 
 
+class BuildExt(build_ext):
+    def finalize_options(self):
+        super().finalize_options()
+        if self.build_temp:
+            for m in self.distribution.ext_modules:
+                m.include_dirs.extend([f"{self.build_temp}/pyrex/spotfire"])
+
+
+class BuildExtDebug(BuildExt):
+    def finalize_options(self):
+        if sys.platform == "win32":
+            log.info("appending debug flags for Windows")
+            for m in self.distribution.ext_modules:
+                m.extra_compile_args.extend(["-Ox", "-Zi"])
+                m.extra_link_args.extend(["-debug:full"])
+        else:
+            log.info("enabling cygdb for Unix")
+            super().cython_gdb = True
+        super().finalize_options()
+
+
 project_requirements = get_requires("spotfire/requirements.txt")
 version = {}
 with open('spotfire/version.py') as ver_file:
@@ -23,6 +49,53 @@ def get_requires(filename):
 with open("README.md", "r") as fh:
     long_description = fh.read()
 
+if sys.platform == "win32":
+    cabfile_libraries = ['cabinet']
+    codesign_includes = ['vendor/windows']
+    codesign_libraries = ['crypt32']
+else:
+    cabfile_libraries = []
+    codesign_includes = []
+    codesign_libraries = []
+extensions = [
+    Extension("spotfire.cabfile",
+              sources=["spotfire/cabfile.pyx",
+                       "spotfire/cabfile_helpers.c",
+                       ],
+              include_dirs=["spotfire"],
+              libraries=cabfile_libraries,
+              cython_c_in_temp=True
+              ),
+    Extension("spotfire.codesign",
+              sources=["spotfire/codesign.pyx"],
+              include_dirs=codesign_includes,
+              libraries=codesign_libraries,
+              cython_c_in_temp=True
+              ),
+    Extension("spotfire.sbdf",
+              sources=["spotfire/sbdf.pyx",
+                       "spotfire/sbdf_helpers.c",
+                       "vendor/sbdf-c/src/metadata.c",
+                       "vendor/sbdf-c/src/valuearray.c",
+                       "vendor/sbdf-c/src/internals.c",
+                       "vendor/sbdf-c/src/fileheader.c",
+                       "vendor/sbdf-c/src/columnmetadata.c",
+                       "vendor/sbdf-c/src/bswap.c",
+                       "vendor/sbdf-c/src/sbdfstring.c",
+                       "vendor/sbdf-c/src/errors.c",
+                       "vendor/sbdf-c/src/object.c",
+                       "vendor/sbdf-c/src/columnslice.c",
+                       "vendor/sbdf-c/src/bytearray.c",
+                       "vendor/sbdf-c/src/tablemetadata.c",
+                       "vendor/sbdf-c/src/tableslice.c",
+                       "vendor/sbdf-c/src/valuetype.c",
+                       ],
+              define_macros=[("SBDF_STATIC", None)],
+              include_dirs=["spotfire", "vendor/sbdf-c/include", np.get_include()],
+              cython_c_in_temp=True
+              ),
+]
+
 setup(
     name='spotfire',
     version=version['__version__'],
@@ -34,7 +107,9 @@ def get_requires(filename):
     maintainer_email='spotfirepython@tibco.com',
     url='https://github.com/TIBCOSoftware/spotfire-python',
     license='BSD 3-Clause License',
-    packages=find_packages(exclude=['spotfire.test']),
+    packages=find_packages(exclude=['spotfire.test.files']),
+    ext_modules=extensions,
+    cmdclass={'build_ext': BuildExt, 'build_ext_debug': BuildExtDebug},
     include_package_data=True,
     install_requires=project_requirements,
     python_requires='>=3.7',
@@ -43,5 +118,6 @@ def get_requires(filename):
         'License :: OSI Approved :: BSD License',
         'Operating System :: OS Independent',
         'Programming Language :: Python',
+        'Programming Language :: Cython',
     ],
 )
diff --git a/spotfire/__init__.py b/spotfire/__init__.py
index ea91315..05d8e67 100644
--- a/spotfire/__init__.py
+++ b/spotfire/__init__.py
@@ -4,80 +4,4 @@
 
 """User visible utility functions."""
 
-import typing
-import warnings
-
-import pandas as pd
-
-from spotfire import sbdf
-
-
-# Table and column metadata functions
-
-def copy_metadata(source, destination) -> None:
-    """Copy the table and column metadata from a Pandas object to another.
-
-    :param source: the DataFrame or Series to copy metadata from
-    :param destination: the DataFrame or Series to copy metadata to
-    :raise TypeError: if the types of source and destination do not match
-    """
-    # Verify that types of source and destination match
-    if isinstance(source, pd.DataFrame) and not isinstance(destination, pd.DataFrame):
-        raise TypeError("both source and destination must be DataFrames")
-    if isinstance(source, pd.Series) and not isinstance(destination, pd.Series):
-        raise TypeError("both source and destination must be Series")
-
-    # Handle DataFrames
-    if isinstance(source, pd.DataFrame):
-        with warnings.catch_warnings():
-            warnings.simplefilter("ignore")
-            destination.spotfire_table_metadata = source.spotfire_table_metadata
-        for col in source.columns:
-            try:
-                source1 = source[col]
-                destination1 = destination[col]
-                destination1.spotfire_column_metadata = source1.spotfire_column_metadata
-            except AttributeError:
-                pass
-    # Handle Series
-    elif isinstance(source, pd.Series):
-        destination.spotfire_column_metadata = source.spotfire_column_metadata
-
-
-# Spotfire type functions
-
-def get_spotfire_types(dataframe: pd.DataFrame) -> pd.Series:
-    """Get Spotfire column type names from an imported DataFrame.
-
-    :param dataframe: the DataFrame to get the Spotfire types of
-    :returns: a Series containing the Spotfire types of each column of dataframe
-    """
-    if not isinstance(dataframe, pd.DataFrame):
-        raise TypeError("dataframe is not a DataFrame")
-    spotfire_types = {}
-    for col in dataframe.columns:
-        if 'spotfire_type' in dataframe[col].attrs:
-            spotfire_types[col] = dataframe[col].attrs['spotfire_type']
-        else:
-            spotfire_types[col] = None
-    return pd.Series(spotfire_types)
-
-
-def set_spotfire_types(dataframe: pd.DataFrame, column_types: typing.Dict[str, str]) -> None:
-    """Set Spotfire column types to use when exporting a DataFrame to SBDF.  If any column name or type is invalid,
-    a warning will be issued, but any other valid assignments will succeed.
-
-    :param dataframe: the DataFrame to set the Spotfire types of
-    :param column_types: dictionary that maps column names to column types
-    """
-    if not isinstance(dataframe, pd.DataFrame):
-        raise TypeError("dataframe is not a DataFrame")
-    for col, spotfire_type in column_types.items():
-        if col not in dataframe:
-            warnings.warn(f"Column '{col}' not found in data")
-            continue
-        # pylint: disable=protected-access
-        if not sbdf._ValueTypeId.from_typename_string(spotfire_type):
-            warnings.warn(f"Spotfire type '{spotfire_type}' for column '{col}' not recognized")
-            continue
-        dataframe[col].attrs['spotfire_type'] = spotfire_type
+from spotfire.public import copy_metadata, get_spotfire_types, set_spotfire_types
diff --git a/spotfire/cabfile.py b/spotfire/cabfile.py
deleted file mode 100644
index 2bcf51a..0000000
--- a/spotfire/cabfile.py
+++ /dev/null
@@ -1,77 +0,0 @@
-# Copyright © 2021. Cloud Software Group, Inc.
-# This file is subject to the license terms contained
-# in the license file that is distributed with this file.
-
-"""Tools to create Microsoft cabinet files.  Only defined on Windows platforms."""
-
-import sys
-
-if sys.platform == "win32":
-    # pylint: disable=deprecated-module
-    import msilib
-    import os
-    import typing
-    from spotfire import _utils
-
-    class CabFile:
-        """Class with methods to open, write, and close Microsoft cabinet files."""
-        def __init__(self, file: str) -> None:
-            """Open the cabinet file.
-
-            :param file: filename of the cabinet file
-            """
-            self.filename = file
-            self._contents = []
-            self._temp_files = _utils.TempFiles()
-            self._opened = True
-
-        def __enter__(self):
-            return self
-
-        def __exit__(self, exc_type, exc_val, exc_tb):
-            return self.close()
-
-        def __repr__(self) -> str:
-            result = [f"<{self.__class__.__module__}.{self.__class__.__qualname__}"]
-            if not self._opened:
-                result.append(" [closed]")
-            elif self.filename is not None:
-                result.append(f" filename={self.filename!r}")
-            result.append(">")
-            return "".join(result)
-
-        def write(self, filename: str, arcname=None) -> None:
-            """Put a file into the cabinet.
-
-            :param filename: the filename of the file to put in the cabinet
-            :param arcname: the name in the cabinet the file should be placed under.  If ``None``, use the basename
-             (without path information) of filename.
-            """
-            if not self._opened:
-                raise ValueError("Attempt to write to cabinet that was already closed")
-
-            if arcname is None:
-                arcname = os.path.basename(filename)
-            self._contents.append((filename, arcname))
-
-        def writestr(self, arcname: str, data: typing.Union[str, bytes]) -> None:
-            """Write arbitrary data into the cabinet.
-
-            :param arcname: the name in the cabinet the data should be placed under
-            :param data: the data to place in the cabinet
-            """
-            if not self._opened:
-                raise ValueError("Attempt to write to cabinet that was already closed")
-
-            # write the data out to a temp file
-            temp = self._temp_files.new_file()
-            temp.file.write(data)
-            temp.close()
-            self._contents.append((temp.name, arcname))
-
-        def close(self) -> None:
-            """Close the cabinet and write the contents to disk."""
-            if self._opened:
-                msilib.FCICreate(self.filename, self._contents)
-                self._temp_files.cleanup()
-                self._opened = False
diff --git a/spotfire/cabfile.pyx b/spotfire/cabfile.pyx
new file mode 100644
index 0000000..22379f9
--- /dev/null
+++ b/spotfire/cabfile.pyx
@@ -0,0 +1,162 @@
+# cython: language_level=3
+
+# Copyright © 2023. Cloud Software Group, Inc.
+# This file is subject to the license terms contained
+# in the license file that is distributed with this file.
+
+"""Tools to create Microsoft cabinet files.  Only defined on Windows platforms."""
+
+cimport cython
+
+IF UNAME_SYSNAME == "Windows":
+
+    import os
+    import tempfile
+
+    from libc cimport limits, string
+    from vendor.windows cimport fci
+
+    include "cabfile_helpers.pxi"
+
+    @cython.auto_pickle(False)
+    cdef class CabFile:
+        """Class with methods to open, write, and close Microsoft cabinet files."""
+        cdef readonly str filename
+        cdef fci.HFCI _hfci
+        cdef fci.CCAB _ccab
+        cdef fci.ERF _erf
+        cdef bint _opened
+
+        def __init__(self, file):
+            """Open the cabinet file.
+
+            :param file: filename of the cabinet file
+            """
+            self.filename = str(file)
+
+            # Start filling in the CCAB structure
+            self._ccab.cb = limits.INT_MAX
+            self._ccab.cbFolderThresh = 1000000
+            self._ccab.cbReserveCFData = 0
+            self._ccab.cbReserveCFFolder = 0
+            self._ccab.cbReserveCFHeader = 0
+            self._ccab.iCab = 1
+            self._ccab.iDisk = 1
+            self._ccab.setID = 0
+            self._ccab.szDisk[0] = 0
+            split_filename = self.filename.replace("/", "\\").rsplit("\\", 1)
+            if len(split_filename) == 1:
+                split_filename.insert(0, '.')
+            split_filename[0] += '\\'
+            string.strcpy(<char*>self._ccab.szCabPath, split_filename[0].encode('utf-8'))
+            string.strcpy(<char*>self._ccab.szCab, split_filename[1].encode('utf-8'))
+
+            # Create the FCI context
+            self._hfci = fci.FCICreate(&self._erf, _fci_cb_file_placed, _fci_cb_alloc, _fci_cb_free, _fci_cb_open,
+                                       _fci_cb_read, _fci_cb_write, _fci_cb_close, _fci_cb_seek, _fci_cb_delete,
+                                       _fci_cb_get_temp_file, &self._ccab, NULL)
+            if self._hfci == NULL:
+                raise ValueError(_fci_error_to_string(&self._erf))
+
+            self._opened = True
+
+        def __enter__(self):
+            return self
+
+        def __exit__(self, exc_type, exc_val, exc_tb):
+            return self.close()
+
+        def __repr__(self):
+            result = [f"<{self.__class__.__module__}.{self.__class__.__qualname__}"]
+            if not self._opened:
+                result.append(" [closed]")
+            elif self.filename is not None:
+                result.append(f" filename={self.filename!r}")
+            result.append(">")
+            return "".join(result)
+
+        def write(self, filename, arcname=None):
+            """Put a file into the cabinet.
+
+            :param filename: the filename of the file to put in the cabinet
+            :param arcname: the name in the cabinet the file should be placed under.  If ``None``, use the basename
+             (without path information) of filename.
+            """
+            if not self._opened:
+                raise ValueError("Attempt to write to cabinet that was already closed")
+
+            if arcname is None:
+                arcname = os.path.basename(filename)
+            if not fci.FCIAddFile(self._hfci, filename.encode('utf-8'), arcname.encode('utf-8'), False,
+                                  _fci_cb_get_next_cabinet, _fci_cb_status, _fci_cb_get_open_info, fci.tcompTYPE_MSZIP):
+                raise ValueError(_fci_error_to_string(&self._erf))
+
+        def writestr(self, arcname, data):
+            """Write arbitrary data into the cabinet.
+
+            :param arcname: the name in the cabinet the data should be placed under
+            :param data: the data to place in the cabinet
+            """
+            if not self._opened:
+                raise ValueError("Attempt to write to cabinet that was already closed")
+
+            with tempfile.NamedTemporaryFile(delete=False) as f:
+                f.write(data)
+                f.close()
+                if not fci.FCIAddFile(self._hfci, f.name.encode('utf-8'), arcname.encode('utf-8'), False,
+                                      _fci_cb_get_next_cabinet, _fci_cb_status, _fci_cb_get_open_info,
+                                      fci.tcompTYPE_MSZIP):
+                    os.unlink(f.name)
+                    raise ValueError(_fci_error_to_string(&self._erf))
+                os.unlink(f.name)
+
+        def close(self):
+            """Close the cabinet and write the contents to disk."""
+            if self._opened:
+                # Flush the cabinet
+                if not fci.FCIFlushCabinet(self._hfci, False, _fci_cb_get_next_cabinet, _fci_cb_status):
+                    raise ValueError(_fci_error_to_string(&self._erf))
+
+                # Destroy the FCI context
+                if self._hfci != NULL:
+                    if not fci.FCIDestroy(self._hfci):
+                        raise ValueError(_fci_error_to_string(&self._erf))
+                self._opened = False
+
+    cdef str _fci_error_to_string(fci.ERF* erf):
+        if erf.fError:
+            if erf.erfOper == fci.FCIERR_NONE:
+                return "No error"
+            elif erf.erfOper == fci.FCIERR_OPEN_SRC:
+                return "Failure opening file to be stored in cabinet"
+            elif erf.erfOper == fci.FCIERR_READ_SRC:
+                return "Failure reading file to be stored in cabinet"
+            elif erf.erfOper == fci.FCIERR_ALLOC_FAIL:
+                return "Insufficient memory in FCI"
+            elif erf.erfOper == fci.FCIERR_TEMP_FILE:
+                return "Could not create temporary file"
+            elif erf.erfOper == fci.FCIERR_BAD_COMPR_TYPE:
+                return "Unknown compression type"
+            elif erf.erfOper == fci.FCIERR_CAB_FILE:
+                return "Could not create cabinet file"
+            elif erf.erfOper == fci.FCIERR_USER_ABORT:
+                return "Client requested abort"
+            elif erf.erfOper == fci.FCIERR_MCI_FAIL:
+                return "Failure compressing data"
+            elif erf.erfOper == fci.FCIERR_CAB_FORMAT_LIMIT:
+                return "Cabinet format limits (either data size or file count) exceeded"
+            else:
+                return f"Unknown FCI error ({erf.erfOper})"
+        else:
+            return "General FCI error"
+
+ELSE:
+
+    class CabFile:
+        """Class with methods to open, write, and close Microsoft cabinet files."""
+        def __init__(self, file: str) -> None:
+            """Open the cabinet file.
+
+            :param file: filename of the cabinet file
+            """
+            raise OSError("Cabinet files not supported on non-Win32 platforms")
diff --git a/spotfire/cabfile_helpers.c b/spotfire/cabfile_helpers.c
new file mode 100644
index 0000000..e4dd711
--- /dev/null
+++ b/spotfire/cabfile_helpers.c
@@ -0,0 +1,152 @@
+/* Copyright (c) 2023. Cloud Software Group, Inc.
+   This file is subject to the license terms contained
+   in the license file that is distributed with this file. */
+
+#include <Python.h>
+
+#ifdef _WIN32
+
+#include <windows.h>
+#include <fcntl.h>
+#include <fci.h>
+
+/* Utility functions for use by the FCI callbacks; they are not exposed to the PYX file */
+static wchar_t *_fci_convert_utf_to_wide(const char *utf, int *err) {
+    PyObject *obj = PyUnicode_FromString(utf);
+    if (obj == NULL) {
+        if (PyErr_ExceptionMatches(PyExc_MemoryError)) {
+            *err = ENOMEM;
+        }
+        else {
+            *err = EINVAL;
+        }
+        PyErr_Clear();
+        return NULL;
+    }
+    wchar_t *wide = PyUnicode_AsWideCharString(obj, NULL);
+    if (wide == NULL) {
+        *err = ENOMEM;
+        PyErr_Clear();
+    }
+    Py_DECREF(obj);
+    return wide;
+}
+
+/* FCI callback functions */
+FNFCIALLOC(_fci_cb_alloc) {
+    return PyMem_RawMalloc(cb);
+}
+
+FNFCIFREE(_fci_cb_free) {
+    PyMem_RawFree(memory);
+}
+
+FNFCIOPEN(_fci_cb_open) {
+    wchar_t *wide = _fci_convert_utf_to_wide(pszFile, err);
+    if (wide == NULL) {
+        return -1;
+    }
+    int result = _wopen(wide, oflag | O_NOINHERIT, pmode);
+    PyMem_Free(wide);
+    if (result == -1)
+        *err = errno;
+    return result;
+}
+
+FNFCIREAD(_fci_cb_read) {
+    UINT result = (UINT)_read((int)hf, memory, cb);
+    if (result != cb)
+        *err = errno;
+    return result;
+}
+
+FNFCIWRITE(_fci_cb_write) {
+    UINT result = (UINT)_write((int)hf, memory, cb);
+    if (result != cb)
+        *err = errno;
+    return result;
+}
+
+FNFCICLOSE(_fci_cb_close) {
+    int result = _close((int)hf);
+    if (result != 0)
+        *err = errno;
+    return result;
+}
+
+FNFCISEEK(_fci_cb_seek) {
+    long result = (long)_lseek((int)hf, dist, seektype);
+    if (result == -1)
+        *err = errno;
+    return result;
+}
+
+FNFCIDELETE(_fci_cb_delete) {
+    wchar_t *wide = _fci_convert_utf_to_wide(pszFile, err);
+    if (wide == NULL) {
+        return -1;
+    }
+    int result = _wremove(wide);
+    PyMem_Free(wide);
+    if (result != 0)
+        *err = errno;
+    return result;
+}
+
+FNFCIFILEPLACED(_fci_cb_file_placed) {
+    return 0;
+}
+
+FNFCIGETTEMPFILE(_fci_cb_get_temp_file) {
+    char *name = _tempnam("", "cabtmp");
+    if ((name != NULL) && ((int)strlen(name) < cbTempName)) {
+        strcpy(pszTempName, name);
+        free(name);
+        return TRUE;
+    }
+
+    if (name) free(name);
+    return FALSE;
+}
+
+FNFCISTATUS(_fci_cb_status) {
+    return 0;
+}
+
+FNFCIGETNEXTCABINET(_fci_cb_get_next_cabinet) {
+    return TRUE;
+}
+
+FNFCIGETOPENINFO(_fci_cb_get_open_info) {
+    wchar_t *wide = _fci_convert_utf_to_wide(pszName, err);
+    if (wide == NULL) {
+        return -1;
+    }
+
+    /* Get file date/time */
+    HANDLE handle = CreateFileW(wide, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
+    if (handle == INVALID_HANDLE_VALUE) {
+        PyMem_Free(wide);
+        return -1;
+    }
+    FILETIME ft;
+    if(GetFileTime(handle, NULL, NULL, &ft) == FALSE) {
+        CloseHandle(handle);
+        PyMem_Free(wide);
+        return -1;
+    }
+    CloseHandle(handle);
+    FileTimeToDosDateTime(&ft, pdate, ptime);
+
+    /* Get file attributes */
+    DWORD attrs = GetFileAttributesW(wide);
+    *pattribs = attrs & (_A_RDONLY | _A_SYSTEM | _A_HIDDEN | _A_ARCH);
+
+    int result = _wopen(wide, _O_RDONLY | _O_BINARY | O_NOINHERIT);
+    PyMem_Free(wide);
+    return result;
+}
+
+#else  /* _WIN32 */
+#endif /* _WIN32 */
+
diff --git a/spotfire/cabfile_helpers.h b/spotfire/cabfile_helpers.h
new file mode 100644
index 0000000..4c6efe6
--- /dev/null
+++ b/spotfire/cabfile_helpers.h
@@ -0,0 +1,17 @@
+/* Copyright (c) 2023. Cloud Software Group, Inc.
+   This file is subject to the license terms contained
+   in the license file that is distributed with this file. */
+
+extern FNFCIALLOC(_fci_cb_alloc);
+extern FNFCIFREE(_fci_cb_free);
+extern FNFCIOPEN(_fci_cb_open);
+extern FNFCIREAD(_fci_cb_read);
+extern FNFCIWRITE(_fci_cb_write);
+extern FNFCICLOSE(_fci_cb_close);
+extern FNFCISEEK(_fci_cb_seek);
+extern FNFCIDELETE(_fci_cb_delete);
+extern FNFCIFILEPLACED(_fci_cb_file_placed);
+extern FNFCIGETTEMPFILE(_fci_cb_get_temp_file);
+extern FNFCISTATUS(_fci_cb_status);
+extern FNFCIGETNEXTCABINET(_fci_cb_get_next_cabinet);
+extern FNFCIGETOPENINFO(_fci_cb_get_open_info);
diff --git a/spotfire/cabfile_helpers.pxi b/spotfire/cabfile_helpers.pxi
new file mode 100644
index 0000000..c23120c
--- /dev/null
+++ b/spotfire/cabfile_helpers.pxi
@@ -0,0 +1,19 @@
+# Copyright © 2023. Cloud Software Group, Inc.
+# This file is subject to the license terms contained
+# in the license file that is distributed with this file.
+
+cdef extern from "cabfile_helpers.h":
+    ### FCI callback functions
+    fci.PFNFCIALLOC _fci_cb_alloc
+    fci.PFNFCIFREE _fci_cb_free
+    fci.PFNFCIOPEN _fci_cb_open
+    fci.PFNFCIREAD _fci_cb_read
+    fci.PFNFCIWRITE _fci_cb_write
+    fci.PFNFCICLOSE _fci_cb_close
+    fci.PFNFCISEEK _fci_cb_seek
+    fci.PFNFCIDELETE _fci_cb_delete
+    fci.PFNFCIFILEPLACED _fci_cb_file_placed
+    fci.PFNFCIGETTEMPFILE _fci_cb_get_temp_file
+    fci.PFNFCISTATUS _fci_cb_status
+    fci.PFNFCIGETNEXTCABINET _fci_cb_get_next_cabinet
+    fci.PFNFCIGETOPENINFO _fci_cb_get_open_info
diff --git a/spotfire/codesign.py b/spotfire/codesign.py
deleted file mode 100644
index 443a4f5..0000000
--- a/spotfire/codesign.py
+++ /dev/null
@@ -1,345 +0,0 @@
-# Copyright © 2021. Cloud Software Group, Inc.
-# This file is subject to the license terms contained
-# in the license file that is distributed with this file.
-
-"""Tools to apply Authenticode code signing signatures and timestamps to files using the native Microsoft APIs.
-Only defined on Windows platforms."""
-
-import sys
-
-if sys.platform == "win32":
-    # pylint: disable=invalid-name,too-few-public-methods,attribute-defined-outside-init
-    import ctypes
-    from ctypes import wintypes
-    import os
-    import typing
-
-    # Constant declarations
-    _S_OK = 0
-
-    _X509_ASN_ENCODING = 0x00010000
-    _PKCS_7_ASN_ENCODING = 0x00000001
-
-    _CERT_FIND_ANY = 0
-    _CERT_KEY_SPEC_PROP_ID = 6
-    _CERT_CLOSE_STORE_CHECK_FLAG = 2
-
-    _SIGNER_SUBJECT_FILE = 1
-    _SIGNER_CERT_POLICY_CHAIN = 2
-    _SIGNER_CERT_STORE = 2
-    _SIGNER_AUTHCODE_ATTR = 1
-
-    _SIGNER_TIMESTAMP_AUTHENTICODE = 1
-    _SIGNER_TIMESTAMP_RFC3161 = 2
-
-    _CALG_SHA_256 = 0x800C
-    _CALG_SHA1 = 0x8004
-    _OID_SHA256 = b"2.16.840.1.101.3.4.2.1"
-    _OID_SHA1 = b"1.3.14.3.2.26"
-
-    # Type declarations
-    _PCCERT_CONTEXT = ctypes.c_void_p
-    _PSIGNER_CONTEXT = ctypes.c_void_p
-    _PSIGNER_PROVIDER_INFO = ctypes.c_void_p
-    _ALG_ID = ctypes.c_uint
-    _PCRYPT_ATTRIBUTES = ctypes.c_void_p
-
-    class _CRYPT_DATA_BLOB(ctypes.Structure):
-        _fields_ = [("cbData", wintypes.DWORD),
-                    ("pbData", ctypes.c_char_p)]
-
-    class _SIGNER_FILE_INFO(ctypes.Structure):
-        _fields_ = [("cbSize", wintypes.DWORD),
-                    ("pwszFileName", wintypes.LPCWSTR),
-                    ("hFile", wintypes.HANDLE)]
-
-    class _SIGNER_SUBJECT_INFO(ctypes.Structure):
-        _fields_ = [("cbSize", wintypes.DWORD),
-                    ("pdwIndex", wintypes.LPDWORD),
-                    ("dwSubjectChoice", wintypes.DWORD),
-                    ("pSignerFileInfo", ctypes.POINTER(_SIGNER_FILE_INFO))]
-
-    class _SIGNER_CERT_STORE_INFO(ctypes.Structure):
-        _fields_ = [("cbSize", wintypes.DWORD),
-                    ("pSigningCert", _PCCERT_CONTEXT),
-                    ("dwCertPolicy", wintypes.DWORD),
-                    ("hCertStore", wintypes.HANDLE)]
-
-    class _SIGNER_CERT(ctypes.Structure):
-        _fields_ = [("cbSize", wintypes.DWORD),
-                    ("dwCertChoice", wintypes.DWORD),
-                    ("pCertStoreInfo", ctypes.POINTER(_SIGNER_CERT_STORE_INFO)),
-                    ("hwnd", wintypes.HWND)]
-
-    class _SIGNER_ATTR_AUTHCODE(ctypes.Structure):
-        _fields_ = [("cbSize", wintypes.DWORD),
-                    ("fCommercial", wintypes.BOOL),
-                    ("fIndividual", wintypes.BOOL),
-                    ("pwszName", wintypes.LPCWSTR),
-                    ("pwszInfo", wintypes.LPCWSTR)]
-
-    class _SIGNER_SIGNATURE_INFO(ctypes.Structure):
-        _fields_ = [("cbSize", wintypes.DWORD),
-                    ("algidHash", _ALG_ID),
-                    ("dwAttrChoice", wintypes.DWORD),
-                    ("pAttrAuthcode", ctypes.POINTER(_SIGNER_ATTR_AUTHCODE)),
-                    ("psAuthenticated", _PCRYPT_ATTRIBUTES),
-                    ("psUnauthenticated", _PCRYPT_ATTRIBUTES)]
-
-    # Parameter declarations
-    def _wrap_function(lib, function_name, restype, argtypes):
-        """Simplify wrapping ctypes functions"""
-        func = getattr(lib, function_name)
-        func.restype = restype
-        func.argtypes = argtypes
-        return func
-
-
-    _pfx_import_cert_store = _wrap_function(ctypes.windll.crypt32,
-                                            "PFXImportCertStore",
-                                            wintypes.HANDLE,
-                                            [ctypes.POINTER(_CRYPT_DATA_BLOB),
-                                             wintypes.LPCWSTR,
-                                             wintypes.DWORD])
-
-    _cert_close_store = _wrap_function(ctypes.windll.crypt32,
-                                       "CertCloseStore",
-                                       wintypes.BOOL,
-                                       [wintypes.HANDLE,
-                                        wintypes.DWORD])
-
-    _cert_find_cert_in_store = _wrap_function(ctypes.windll.crypt32,
-                                              "CertFindCertificateInStore",
-                                              _PCCERT_CONTEXT,
-                                              [wintypes.HANDLE,
-                                               wintypes.DWORD,
-                                               wintypes.DWORD,
-                                               wintypes.DWORD,
-                                               ctypes.c_void_p,
-                                               _PCCERT_CONTEXT])
-
-    _cert_get_context_property = _wrap_function(ctypes.windll.crypt32,
-                                                "CertGetCertificateContextProperty",
-                                                wintypes.BOOL,
-                                                [_PCCERT_CONTEXT,
-                                                 wintypes.DWORD,
-                                                 ctypes.c_void_p,
-                                                 wintypes.LPDWORD])
-
-    _cert_free_context = _wrap_function(ctypes.windll.crypt32,
-                                        "CertFreeCertificateContext",
-                                        wintypes.BOOL,
-                                        [_PCCERT_CONTEXT])
-
-    _signer_sign_ex = _wrap_function(ctypes.windll.mssign32,
-                                     "SignerSignEx",
-                                     ctypes.HRESULT,
-                                     [wintypes.DWORD,
-                                      ctypes.POINTER(_SIGNER_SUBJECT_INFO),
-                                      ctypes.POINTER(_SIGNER_CERT),
-                                      ctypes.POINTER(_SIGNER_SIGNATURE_INFO),
-                                      _PSIGNER_PROVIDER_INFO,
-                                      wintypes.LPCWSTR,
-                                      _PCRYPT_ATTRIBUTES,
-                                      ctypes.c_void_p,
-                                      ctypes.POINTER(_PSIGNER_CONTEXT)])
-
-    _signer_timestamp = _wrap_function(ctypes.windll.mssign32,
-                                       "SignerTimeStamp",
-                                       ctypes.HRESULT,
-                                       [ctypes.POINTER(_SIGNER_SUBJECT_INFO),
-                                        wintypes.LPCWSTR,
-                                        _PCRYPT_ATTRIBUTES,
-                                        ctypes.c_void_p])
-
-    _signer_timestamp_ex2 = _wrap_function(ctypes.windll.mssign32,
-                                           "SignerTimeStampEx2",
-                                           ctypes.HRESULT,
-                                           [wintypes.DWORD,
-                                            ctypes.POINTER(_SIGNER_SUBJECT_INFO),
-                                            wintypes.LPCWSTR,
-                                            ctypes.c_char_p,
-                                            _PCRYPT_ATTRIBUTES,
-                                            ctypes.c_void_p,
-                                            ctypes.POINTER(_PSIGNER_CONTEXT)])
-
-    _signer_free_context = _wrap_function(ctypes.windll.mssign32,
-                                          "SignerFreeSignerContext",
-                                          ctypes.HRESULT,
-                                          [_PSIGNER_CONTEXT])
-
-    # Public functions
-
-    # noinspection PyUnboundLocalVariable
-    def codesign_file(filename: str,
-                      certificate: str,
-                      password: typing.Optional[str],
-                      timestamp: typing.Optional[str] = None,
-                      use_rfc3161: bool = False,
-                      use_sha256: bool = False) -> None:
-        """Codesign a file using the Microsoft signing API found in mssign32.dll.
-
-        :param filename: the filename of the file to codesign
-        :param certificate: the filename of the certificate file to codesign with
-        :param password: the password used to unlock the certificate
-        :param timestamp: a URL of the timestamping service to timestamp the code signature with
-        :param use_rfc3161: whether or not to use the RFC 3161 timestamping protocol.  If ``True``, use RFC 3161.
-          If ``False``, use Authenticode.
-        :param use_sha256: whether or not to use SHA-256 as the timestamping hash function.  If ``True``, use SHA-256.
-          If ``False``, use SHA-1.
-        """
-        try:
-            # Sanity check arguments
-            if not os.path.isfile(filename):
-                raise FileNotFoundError(f"No such file: '{filename}'")
-            if not os.path.isfile(certificate):
-                raise FileNotFoundError(f"No such file: '{certificate}'")
-            if use_sha256 and not use_rfc3161:
-                raise Exception("SHA-256 timestamping requires the RFC 3161 timestamping protocol")
-
-            # Open the certificate file and convert it into an in-memory cert store
-            cert_store = _open_certificate(certificate, password)
-
-            # Extract the cert from the new cert store
-            cert_context = _extract_certificate(cert_store)
-
-            # Prepare structures
-            index = wintypes.DWORD()
-            signer_subject_info = _create_subject(index, filename)
-
-            # Sign the file
-            _sign(cert_context, use_sha256, signer_subject_info)
-
-            # Timestamp the file
-            _timestamp(signer_subject_info, timestamp, use_rfc3161, use_sha256)
-
-        finally:
-            try:
-                if cert_context is not None:
-                    _cert_free_context(cert_context)
-            except NameError:
-                pass
-
-            try:
-                if cert_store is not None:
-                    _cert_close_store(cert_store, _CERT_CLOSE_STORE_CHECK_FLAG)
-            except NameError:
-                pass
-
-
-    def _open_certificate(certificate, password):
-        """Open the certificate file and convert it into an in-memory cert store."""
-        with open(certificate, "rb") as cert:
-            cert_data = cert.read()
-        cert_blob = _CRYPT_DATA_BLOB(len(cert_data), cert_data)
-        cert_store = _pfx_import_cert_store(ctypes.byref(cert_blob), password, 0)
-        if cert_store is None:
-            cert_store = _pfx_import_cert_store(ctypes.byref(cert_blob), "", 0)
-        if cert_store is None:
-            cert_store = _pfx_import_cert_store(ctypes.byref(cert_blob), None, 0)
-        if cert_store is None:
-            raise Exception(f"Could not load certificate; is the password correct? (0x{ctypes.GetLastError():08x})")
-        return cert_store
-
-
-    def _extract_certificate(cert_store):
-        """Extract the cert from the new cert store."""
-        cert_context = _cert_find_cert_in_store(cert_store, _X509_ASN_ENCODING | _PKCS_7_ASN_ENCODING, 0,
-                                                _CERT_FIND_ANY, None, None)
-        if cert_context is None:
-            raise Exception(f"Could not get certificate from store (0x{ctypes.GetLastError():08x})")
-        key_spec = wintypes.DWORD()
-        found_private_key = False
-        while not found_private_key:
-            key_spec_len = wintypes.DWORD(ctypes.sizeof(key_spec))
-            has_private_key = _cert_get_context_property(cert_context, _CERT_KEY_SPEC_PROP_ID,
-                                                         ctypes.byref(key_spec), ctypes.byref(key_spec_len))
-            if has_private_key:
-                found_private_key = True
-            else:
-                cert_context = _cert_find_cert_in_store(cert_store, _X509_ASN_ENCODING | _PKCS_7_ASN_ENCODING, 0,
-                                                        _CERT_FIND_ANY, None, cert_context)
-                if cert_context is None:
-                    raise Exception(f"Could not get certificate from store (0x{ctypes.GetLastError():08x})")
-        return cert_context
-
-
-    def _create_subject(index, filename):
-        """Create a subject information structure for a given file."""
-        signer_file_info = _SIGNER_FILE_INFO()
-        signer_file_info.cbSize = ctypes.sizeof(_SIGNER_FILE_INFO)
-        signer_file_info.pwszFileName = filename
-        signer_file_info.hFile = None
-
-        signer_subject_info = _SIGNER_SUBJECT_INFO()
-        signer_subject_info.cbSize = ctypes.sizeof(_SIGNER_SUBJECT_INFO)
-        signer_subject_info.pdwIndex = ctypes.pointer(index)
-        signer_subject_info.dwSubjectChoice = _SIGNER_SUBJECT_FILE
-        signer_subject_info.pSignerFileInfo = ctypes.pointer(signer_file_info)
-
-        return signer_subject_info
-
-
-    def _sign(cert_context, use_sha256, signer_subject_info):
-        """Sign the file."""
-        signer_cert_store_info = _SIGNER_CERT_STORE_INFO()
-        signer_cert_store_info.cbSize = ctypes.sizeof(_SIGNER_CERT_STORE_INFO)
-        signer_cert_store_info.pSigningCert = cert_context
-        signer_cert_store_info.dwCertPolicy = _SIGNER_CERT_POLICY_CHAIN
-        signer_cert_store_info.hCertStore = None
-
-        signer_cert = _SIGNER_CERT()
-        signer_cert.cbSize = ctypes.sizeof(_SIGNER_CERT)
-        signer_cert.dwCertChoice = _SIGNER_CERT_STORE
-        signer_cert.pCertStoreInfo = ctypes.pointer(signer_cert_store_info)
-        signer_cert.hwnd = None
-
-        signer_attr_authcode = _SIGNER_ATTR_AUTHCODE()
-        signer_attr_authcode.cbSize = ctypes.sizeof(_SIGNER_ATTR_AUTHCODE)
-        signer_attr_authcode.fCommercial = False
-        signer_attr_authcode.fIndividual = True
-        signer_attr_authcode.pwszName = ""
-        signer_attr_authcode.pwszInfo = None
-
-        signer_signature_info = _SIGNER_SIGNATURE_INFO()
-        signer_signature_info.cbSize = ctypes.sizeof(_SIGNER_SIGNATURE_INFO)
-        signer_signature_info.algidHash = _CALG_SHA_256 if use_sha256 else _CALG_SHA1
-        signer_signature_info.dwAttrChoice = _SIGNER_AUTHCODE_ATTR
-        signer_signature_info.pAttrAuthcode = ctypes.pointer(signer_attr_authcode)
-        signer_signature_info.psAuthenticated = None
-        signer_signature_info.psUnauthenticated = None
-
-        signer_context = _PSIGNER_CONTEXT()
-        result = _signer_sign_ex(0,
-                                 ctypes.byref(signer_subject_info),
-                                 ctypes.byref(signer_cert),
-                                 ctypes.byref(signer_signature_info),
-                                 None,
-                                 None,
-                                 None,
-                                 None,
-                                 ctypes.byref(signer_context))
-        if signer_context is not None:
-            _signer_free_context(signer_context)
-        if result is not _S_OK:
-            raise Exception(f"Could not sign file (0x{ctypes.GetLastError():08x})")
-
-
-    def _timestamp(signer_subject_info, timestamp, use_rfc3161, use_sha256):
-        """Timestamp the file."""
-        if timestamp is not None:
-            if use_rfc3161:
-                signer_context = _PSIGNER_CONTEXT()
-                result = _signer_timestamp_ex2(_SIGNER_TIMESTAMP_RFC3161,
-                                               ctypes.byref(signer_subject_info),
-                                               timestamp,
-                                               _OID_SHA256 if use_sha256 else _OID_SHA1,
-                                               None,
-                                               None,
-                                               ctypes.byref(signer_context))
-                if signer_context is not None:
-                    _signer_free_context(signer_context)
-            else:
-                result = _signer_timestamp(ctypes.byref(signer_subject_info), timestamp, None, None)
-            if result is not _S_OK:
-                raise Exception(f"Could not timestamp file (0x{ctypes.GetLastError():08x})")
diff --git a/spotfire/codesign.pyx b/spotfire/codesign.pyx
new file mode 100644
index 0000000..90f4548
--- /dev/null
+++ b/spotfire/codesign.pyx
@@ -0,0 +1,204 @@
+# cython: language_level=3
+
+# Copyright © 2021. Cloud Software Group, Inc.
+# This file is subject to the license terms contained
+# in the license file that is distributed with this file.
+
+"""Tools to apply Authenticode code signing signatures and timestamps to files using the native Microsoft APIs.
+Only runs on Windows platforms."""
+
+IF UNAME_SYSNAME == "Windows":
+
+    import os
+
+    from vendor cimport windows
+    from vendor.windows cimport wincrypt, mssign32
+
+    cdef class CodesignError(Exception):
+        """An exception that is raised to indicate a problem during code signing."""
+        def __init__(self, *args):
+            super().__init__(*args)
+            self.winerror = windows.GetLastError()
+
+        cdef windows.DWORD winerror
+
+        def __str__(self):
+            return f"[WinError {self.winerror:08x}] {super().__str__()}"
+
+    # An empty string.  Sometimes no passwords are encoded this way, sometimes as NULL.
+    cdef Py_UNICODE _empty_wstring[1]
+    _empty_wstring[:] = [0]
+
+    cpdef void codesign_file(filename,
+                             certificate,
+                             windows.LPCWSTR password,
+                             windows.LPCWSTR timestamp = NULL,
+                             bint use_rfc3161 = False,
+                             bint use_sha256 = False) except *:
+        """Codesign a file using the Microsoft signing API found in mssign32.dll.
+    
+        :param filename: the filename of the file to codesign
+        :param certificate: the filename of the certificate file to codesign with
+        :param password: the password used to unlock the certificate
+        :param timestamp: a URL of the timestamping service to timestamp the code signature with
+        :param use_rfc3161: whether or not to use the RFC 3161 timestamping protocol.  If ``True``, use RFC 3161.
+          If ``False``, use Authenticode.
+        :param use_sha256: whether or not to use SHA-256 as the timestamping hash function.  If ``True``, use SHA-256.
+          If ``False``, use SHA-1.
+        """
+        cdef windows.HANDLE mssign32_library = NULL
+        cdef mssign32.SignerSignExType signer_sign_ex_fun
+        cdef mssign32.SignerTimeStampType signer_time_stamp_fun
+        cdef mssign32.SignerTimeStampEx2Type signer_time_stamp_ex2_fun
+        cdef mssign32.SignerFreeSignerContextType signer_free_signer_context_fun
+        cdef wincrypt.CRYPT_DATA_BLOB cert_blob
+        cdef windows.HANDLE cert_store = NULL
+        cdef const wincrypt.CERT_CONTEXT* cert_context = NULL
+        cdef windows.DWORD key_spec, key_spec_len
+        cdef mssign32.SIGNER_FILE_INFO signer_file_info
+        cdef mssign32.SIGNER_SUBJECT_INFO signer_subject_info
+        cdef windows.DWORD index
+        cdef mssign32.SIGNER_CERT_STORE_INFO signer_cert_store_info
+        cdef mssign32.SIGNER_CERT signer_cert
+        cdef mssign32.SIGNER_ATTR_AUTHCODE signer_attr_authcode
+        cdef mssign32.SIGNER_SIGNATURE_INFO signer_sig_info
+        cdef mssign32.SIGNER_CONTEXT* signer_context = NULL
+
+        try:
+            # Sanity check arguments
+            if not os.path.isfile(filename):
+                raise FileNotFoundError(f"No such file: '{filename}'")
+            if not os.path.isfile(certificate):
+                raise FileNotFoundError(f"No such file: '{certificate}'")
+            if use_sha256 and not use_rfc3161:
+                raise ValueError("SHA-256 timestamping requires the RFC 3161 timestamping protocol")
+
+            # Load DLL and functions from mssign32.dll
+            mssign32_library = windows.LoadLibrary("mssign32.dll")
+            if mssign32_library is NULL:
+                raise CodesignError("Cannot load mssign32.dll")
+            signer_sign_ex_fun = <mssign32.SignerSignExType>windows.GetProcAddress(mssign32_library, "SignerSignEx")
+            if signer_sign_ex_fun is NULL:
+                raise CodesignError("Cannot find function 'SignerSignEx'")
+            signer_time_stamp_fun = <mssign32.SignerTimeStampType>windows.GetProcAddress(mssign32_library, "SignerTimeStamp")
+            if signer_time_stamp_fun is NULL:
+                raise CodesignError("Cannot find function 'SignerTimeStamp'")
+            signer_time_stamp_ex2_fun = <mssign32.SignerTimeStampEx2Type>windows.GetProcAddress(mssign32_library, "SignerTimeStampEx2")
+            if signer_time_stamp_ex2_fun is NULL:
+                raise CodesignError("Cannot find function 'SignerTimeStampEx2'")
+            signer_free_signer_context_fun = <mssign32.SignerFreeSignerContextType>windows.GetProcAddress(mssign32_library, "SignerFreeSignerContext")
+            if signer_free_signer_context_fun is NULL:
+                raise CodesignError("Cannot find function 'SignerFreeSignerContext'")
+
+            # Open the certificate file and convert it into an in-memory cert store
+            with open(certificate, "rb") as cert:
+                cert_data = cert.read()
+            cert_blob.cbData = <windows.DWORD>len(cert_data)
+            cert_blob.pbData = <char*>cert_data
+            cert_store = wincrypt.PFXImportCertStore(&cert_blob, password, 0)
+            if cert_store is NULL:
+                cert_store = wincrypt.PFXImportCertStore(&cert_blob, _empty_wstring, 0)
+            if cert_store is NULL:
+                cert_store = wincrypt.PFXImportCertStore(&cert_blob, NULL, 0)
+            if cert_store is NULL:
+                raise CodesignError(f"Could not load certificate {certificate}; is the password correct?")
+
+            # Extract the cert from the new cert store
+            cert_context = wincrypt.CertFindCertificateInStore(cert_store,
+                                                               wincrypt.X509_ASN_ENCODING | wincrypt.PKCS_7_ASN_ENCODING,
+                                                               0, wincrypt.CERT_FIND_ANY, NULL, NULL)
+            if cert_context is NULL:
+                raise CodesignError("Could not get certificate from store")
+            found_private_key = False
+            while not found_private_key:
+                key_spec_len = sizeof(key_spec)
+                has_private_key = wincrypt.CertGetCertificateContextProperty(cert_context,
+                                                                             wincrypt.CERT_KEY_SPEC_PROP_ID,
+                                                                             &key_spec, &key_spec_len)
+                if has_private_key:
+                    found_private_key = True
+                else:
+                    cert_context = wincrypt.CertFindCertificateInStore(cert_store,
+                                                                       wincrypt.X509_ASN_ENCODING | wincrypt.PKCS_7_ASN_ENCODING,
+                                                                       0, wincrypt.CERT_FIND_ANY, NULL, cert_context)
+                    if cert_context is NULL:
+                        raise CodesignError("Could not get certificate from store")
+
+            # Prepare structures
+            signer_file_info.cbSize = sizeof(mssign32.SIGNER_FILE_INFO)
+            signer_file_info.pwszFileName = filename
+            signer_file_info.hFile = NULL
+
+            signer_subject_info.cbSize = sizeof(mssign32.SIGNER_SUBJECT_INFO)
+            index = 0
+            signer_subject_info.pdwIndex = &index
+            signer_subject_info.dwSubjectChoice = mssign32.SIGNER_SUBJECT_FILE
+            signer_subject_info.pSignerFileInfo = &signer_file_info
+
+            signer_cert_store_info.cbSize = sizeof(mssign32.SIGNER_CERT_STORE_INFO)
+            signer_cert_store_info.pSigningCert = cert_context
+            signer_cert_store_info.dwCertPolicy = mssign32.SIGNER_CERT_POLICY_CHAIN
+            signer_cert_store_info.hCertStore = NULL
+
+            signer_cert.cbSize = sizeof(mssign32.SIGNER_CERT)
+            signer_cert.dwCertChoice = mssign32.SIGNER_CERT_STORE
+            signer_cert.pCertStoreInfo = &signer_cert_store_info
+            signer_cert.hwnd = NULL
+
+            signer_attr_authcode.cbSize = sizeof(mssign32.SIGNER_ATTR_AUTHCODE)
+            signer_attr_authcode.fCommercial = False
+            signer_attr_authcode.fIndividual = True
+            signer_attr_authcode.pwszName = _empty_wstring
+            signer_attr_authcode.pwszInfo = NULL
+
+            signer_sig_info.cbSize = sizeof(mssign32.SIGNER_SIGNATURE_INFO)
+            signer_sig_info.algidHash = wincrypt.CALG_SHA_256 if use_sha256 else wincrypt.CALG_SHA1
+            signer_sig_info.dwAttrChoice = mssign32.SIGNER_AUTHCODE_ATTR
+            signer_sig_info.pAttrAuthcode = &signer_attr_authcode
+            signer_sig_info.psAuthenticated = NULL
+            signer_sig_info.psUnauthenticated = NULL
+
+            # Sign the file
+            result = signer_sign_ex_fun(0, &signer_subject_info, &signer_cert, &signer_sig_info, NULL, NULL, NULL, NULL,
+                                        &signer_context)
+            if signer_context is not NULL:
+                signer_free_signer_context_fun(signer_context)
+            if result is not windows.S_OK:
+                raise CodesignError(f"Could not sign file {filename}")
+
+            # Timestamp the file
+            if timestamp is not NULL:
+                if use_rfc3161:
+                    result = signer_time_stamp_ex2_fun(mssign32.SIGNER_TIMESTAMP_RFC3161, &signer_subject_info,
+                                                       timestamp,
+                                                       mssign32.OID_SHA256 if use_sha256 else mssign32.OID_SHA1,
+                                                       NULL, NULL, &signer_context)
+                    if signer_context is not NULL:
+                        signer_free_signer_context_fun(signer_context)
+                else:
+                    result = signer_time_stamp_fun(&signer_subject_info, timestamp, NULL, NULL)
+                if result is not windows.S_OK:
+                    raise CodesignError(f"Could not timestamp file {filename}")
+        finally:
+            if cert_context is not NULL:
+                wincrypt.CertFreeCertificateContext(cert_context)
+            if cert_store is not NULL:
+                wincrypt.CertCloseStore(cert_store, wincrypt.CERT_CLOSE_STORE_CHECK_FLAG)
+            if mssign32_library is not NULL:
+                windows.FreeLibrary(mssign32_library)
+
+ELSE:
+
+    def codesign_file(filename, certificate, password, timestamp = None, use_rfc3161 = False, use_sha256 = False):
+        """Codesign a file using the Microsoft signing API found in mssign32.dll.
+
+        :param filename: the filename of the file to codesign
+        :param certificate: the filename of the certificate file to codesign with
+        :param password: the password used to unlock the certificate
+        :param timestamp: a URL of the timestamping service to timestamp the code signature with
+        :param use_rfc3161: whether or not to use the RFC 3161 timestamping protocol.  If ``True``, use RFC 3161.
+          If ``False``, use Authenticode.
+        :param use_sha256: whether or not to use SHA-256 as the timestamping hash function.  If ``True``, use SHA-256.
+          If ``False``, use SHA-1.
+        """
+        raise OSError("Codesigning not supported on non-Win32 platforms")
diff --git a/spotfire/data_function.py b/spotfire/data_function.py
index 68ff933..ee9cf85 100644
--- a/spotfire/data_function.py
+++ b/spotfire/data_function.py
@@ -213,7 +213,7 @@ def __init__(self, analytic_type: str, inputs: typing.List[AnalyticInput], outpu
         self.outputs = outputs if isinstance(outputs, list) else []
         self.script = script
         self.debug_enabled = False
-        self.globals = dict(__builtins__=__builtins__)
+        self.globals = {'__builtins__': __builtins__}
         self.log = io.StringIO()
         self.compiled_script = None
 
diff --git a/spotfire/public.py b/spotfire/public.py
new file mode 100644
index 0000000..8d32eb5
--- /dev/null
+++ b/spotfire/public.py
@@ -0,0 +1,88 @@
+# Copyright © 2021. Cloud Software Group, Inc.
+# This file is subject to the license terms contained
+# in the license file that is distributed with this file.
+
+"""User visible utility functions."""
+
+import typing
+import warnings
+
+import pandas as pd
+
+from spotfire import sbdf
+
+
+# Table and column metadata functions
+
+def copy_metadata(source, destination) -> None:
+    """Copy the table and column metadata from a Pandas object to another.
+
+    :param source: the DataFrame or Series to copy metadata from
+    :param destination: the DataFrame or Series to copy metadata to
+    :raise TypeError: if the types of source and destination do not match
+    """
+    # Verify that types of source and destination match
+    if isinstance(source, pd.DataFrame) and not isinstance(destination, pd.DataFrame):
+        raise TypeError("both source and destination must be DataFrames")
+    if isinstance(source, pd.Series) and not isinstance(destination, pd.Series):
+        raise TypeError("both source and destination must be Series")
+
+    # Handle DataFrames
+    if isinstance(source, pd.DataFrame):
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            try:
+                destination.spotfire_table_metadata = source.spotfire_table_metadata
+            except AttributeError:
+                pass
+        for col in source.columns:
+            try:
+                source1 = source[col]
+                destination1 = destination[col]
+                destination1.spotfire_column_metadata = source1.spotfire_column_metadata
+            except AttributeError:
+                pass
+    # Handle Series
+    elif isinstance(source, pd.Series):
+        try:
+            destination.spotfire_column_metadata = source.spotfire_column_metadata
+        except AttributeError:
+            pass
+
+
+# Spotfire type functions
+
+def get_spotfire_types(dataframe: pd.DataFrame) -> pd.Series:
+    """Get Spotfire column type names from an imported DataFrame.
+
+    :param dataframe: the DataFrame to get the Spotfire types of
+    :returns: a Series containing the Spotfire types of each column of dataframe
+    """
+    if not isinstance(dataframe, pd.DataFrame):
+        raise TypeError("dataframe is not a DataFrame")
+    spotfire_types = {}
+    for col in dataframe.columns:
+        if 'spotfire_type' in dataframe[col].attrs:
+            spotfire_types[col] = dataframe[col].attrs['spotfire_type']
+        else:
+            spotfire_types[col] = None
+    return pd.Series(spotfire_types)
+
+
+def set_spotfire_types(dataframe: pd.DataFrame, column_types: typing.Dict[str, str]) -> None:
+    """Set Spotfire column types to use when exporting a DataFrame to SBDF.  If any column name or type is invalid,
+    a warning will be issued, but any other valid assignments will succeed.
+
+    :param dataframe: the DataFrame to set the Spotfire types of
+    :param column_types: dictionary that maps column names to column types
+    """
+    if not isinstance(dataframe, pd.DataFrame):
+        raise TypeError("dataframe is not a DataFrame")
+    for col, spotfire_type in column_types.items():
+        if col not in dataframe:
+            warnings.warn(f"Column '{col}' not found in data")
+            continue
+        if not sbdf.spotfire_typename_to_valuetype_id(spotfire_type):
+            warnings.warn(f"Spotfire type '{spotfire_type}' for column '{col}' not recognized", sbdf.SBDFWarning)
+            continue
+        dataframe[col].attrs['spotfire_type'] = spotfire_type
diff --git a/spotfire/requirements.txt b/spotfire/requirements.txt
index 0efd91d..bfb3f1c 100644
--- a/spotfire/requirements.txt
+++ b/spotfire/requirements.txt
@@ -1,6 +1,5 @@
-bitstring
-numpy>=1.21
-pandas>=0.24.0,!=1.0.2,!=1.0.3
+numpy >=1.21
+pandas >=0.24.0,!=1.0.2,!=1.0.3
 pip
 python-dateutil
 pytz
diff --git a/spotfire/sbdf.py b/spotfire/sbdf.py
deleted file mode 100644
index 7278f5c..0000000
--- a/spotfire/sbdf.py
+++ /dev/null
@@ -1,1594 +0,0 @@
-# Copyright © 2021. Cloud Software Group, Inc.
-# This file is subject to the license terms contained
-# in the license file that is distributed with this file.
-
-"""Functions to import data tables into Python from SBDF files and export data from Python to SBDF files."""
-
-import collections.abc
-import datetime
-import decimal
-import enum
-import struct
-import tempfile
-import typing
-import warnings
-
-import bitstring
-import pandas as pd
-import numpy as np
-
-from spotfire import _utils
-
-try:
-    import geopandas as gpd
-    import shapely
-    import shapely.geometry.base as shp_geom
-except ImportError:
-    gpd = None
-    shapely = None
-    shp_geom = None
-
-try:
-    import matplotlib
-    import matplotlib.figure
-except ImportError:
-    matplotlib = None
-
-try:
-    import PIL
-    import PIL.Image
-except ImportError:
-    PIL = None
-
-try:
-    import seaborn
-except ImportError:
-    seaborn = None
-
-__all__ = ["import_data", "export_data"]
-
-
-# Public Functions
-
-
-def import_data(sbdf_file: typing.Union[str, bytes, int]) -> pd.DataFrame:
-    """Import data from an SBDF file and create a 'pandas' DataFrame.
-
-    :param sbdf_file: the filename of the SBDF file to import
-    :return: the DataFrame containing the imported data
-    :raises SBDFError: if a problem is encountered during import
-    """
-
-    # Open the SBDF file
-    with open(sbdf_file, "rb") as file:
-        # Read the file header
-        version_major, version_minor = _FileHeader.read(file)
-        if version_major != _FileHeader.Major_Version or version_minor != _FileHeader.Minor_Version:
-            raise SBDFError(f"unsupported file version {version_major}.{version_minor}")
-
-        # Read the table metadata
-        tmeta = _TableMetadata.read(file)
-
-        # Process table metadata
-        table_metadata_dict = _import_table_metadata(tmeta)
-
-        # Process column metadata
-        pd_data, pd_dtypes, spotfire_types, column_metadata_dict, column_names = _import_column_metadata(tmeta)
-
-        # Read the table slices
-        _import_table_slices(file, column_names, pd_data, tmeta)
-
-        # Construct the pandas DataFrame and return the final results
-        columns = []
-        for col in column_names:
-            columns.append(pd.Series(pd_data[col], dtype=pd_dtypes[col], name=col))
-        dataframe = pd.concat(columns, axis=1)
-        for col in column_names:
-            dataframe[col].spotfire_column_metadata = column_metadata_dict[col]
-            dataframe[col].attrs['spotfire_type'] = spotfire_types[col]
-        if gpd is not None and table_metadata_dict.get('MapChart.IsGeocodingTable'):
-            dataframe = _data_frame_to_geo_data_frame(dataframe, table_metadata_dict)
-        with warnings.catch_warnings():
-            warnings.simplefilter("ignore")
-            dataframe.spotfire_table_metadata = table_metadata_dict
-        return dataframe
-
-
-def _import_table_metadata(tmeta: '_TableMetadata') -> typing.Dict[str, typing.Any]:
-    table_metadata_dict = {}
-    for i in range(tmeta.table_metadata.count()):
-        table_metadata_dict[tmeta.table_metadata.names[i]] = tmeta.table_metadata.values[i].data
-    return table_metadata_dict
-
-
-def _import_column_metadata(tmeta: '_TableMetadata') -> typing.Tuple[typing.Dict[str, typing.List],
-                                                                     typing.Dict[str, str],
-                                                                     typing.Dict[str, str],
-                                                                     typing.Dict[str, typing.Dict[str, typing.Any]],
-                                                                     typing.List[str]]:
-    pd_data = {}
-    pd_dtypes = {}
-    spotfire_types = {}
-    column_metadata_dict = {}
-    column_names = []
-    for i in range(tmeta.column_count()):
-        cmeta = tmeta.column_metadata[i]
-
-        # get the column name
-        cm_column_name = _ColumnMetadata.get_name(cmeta)
-        column_names.append(cm_column_name)
-
-        # add a new list to hold the column
-        pd_data[cm_column_name] = []
-
-        # get the Spotfire type for the column
-        sbdf_type = _ColumnMetadata.get_type(cmeta)
-        spotfire_types[cm_column_name] = sbdf_type.to_typename_string()
-
-        # get the pandas dtype for constructing this column
-        pd_dtypes[cm_column_name] = sbdf_type.to_dtype_name()
-
-        # get the remaining column metadata
-        cm_dict = {}
-        for j in range(cmeta.count()):
-            if cmeta.names[j] in (_ColumnMetadata.Property_Name, _ColumnMetadata.Property_DataType):
-                continue
-            cm_dict[cmeta.names[j]] = cmeta.values[j].data
-        column_metadata_dict[cm_column_name] = cm_dict
-
-    return pd_data, pd_dtypes, spotfire_types, column_metadata_dict, column_names
-
-
-def _import_table_slices(file: typing.BinaryIO, column_names: typing.List[str],
-                         pd_data: typing.Dict[str, typing.List], tmeta: '_TableMetadata') -> None:
-    while True:
-        tslice = _TableSlice.read(file, tmeta, None)
-        if tslice is None:
-            break
-        for i in range(tslice.column_count()):
-            cslice = tslice.columns[i]
-            cs_values = cslice.values.get_values()
-            cs_invalid_prop = cslice.get_property(_ColumnSlice.ValueProperty_IsInvalid)
-            if cs_invalid_prop is None:
-                cs_invalid = [False] * cs_values.get_count()
-            else:
-                cs_invalid = cs_invalid_prop.get_values().data
-            for value, invalid in zip(cs_values.data, cs_invalid):
-                pd_data[column_names[i]].append(None if invalid else value)
-
-
-def export_data(obj: typing.Any, sbdf_file: typing.Union[str, bytes, int], default_column_name: str = "x") -> None:
-    """Export data to an SBDF file.
-
-    :param obj: the data object to export
-    :param sbdf_file: the filename to export the data to
-    :param default_column_name: the column name to use when exporting data objects without intrinsic names (such as
-                                lists or scalar values)
-    :raises SBDFError: if a problem is encountered during export
-    """
-    columns, column_names, column_types, table_metadata, column_metadata = _export_columnize_data(obj,
-                                                                                                  default_column_name)
-
-    # Open the SBDF file
-    with open(sbdf_file, "wb") as file:
-        # Write the file header
-        _FileHeader.write(file)
-
-        # Write the table and column metadata
-        tmeta = _export_table_metadata(table_metadata)
-        row_count = _export_column_metadata(columns, column_names, column_types, column_metadata, tmeta)
-        tmeta.write(file)
-
-        # Write out the table and column slices
-        try:
-            _export_table_slices(columns, column_names, column_types, file, row_count, tmeta)
-        except Exception as exc:
-            raise SBDFError("Failure while exporting data") from exc
-
-
-def _export_columnize_data(obj: typing.Any, default_column_name: str) -> \
-        typing.Tuple[typing.Dict[str, typing.List[typing.Any]],
-                     typing.List[str],
-                     typing.Dict[str, '_ValueTypeId'],
-                     typing.Dict[str, typing.List[typing.Any]],
-                     typing.Dict[str, typing.Dict[str, typing.List[typing.Any]]]
-                     ]:
-    # pylint: disable=too-many-branches,too-many-statements
-    table_metadata = {}
-    column_metadata = {}
-    if isinstance(obj, pd.DataFrame):
-        # Extract the table and column metadata from the data frame
-        try:
-            table_metadata = obj.spotfire_table_metadata
-        except AttributeError:
-            pass
-        for col in obj.columns.tolist():
-            try:
-                col_meta = obj[col].spotfire_column_metadata
-                column_metadata[col] = col_meta
-            except AttributeError:
-                column_metadata[col] = {}
-
-        # Convert geopandas geodataframe to Spotfire's native geocoding format
-        if gpd is not None and isinstance(obj, gpd.GeoDataFrame):
-            obj = _geo_data_frame_to_data_frame(obj, table_metadata, column_metadata)
-
-        if len({str(x) for x in obj.keys()}) != len(obj.columns):
-            raise SBDFError("obj does not have unique column names")
-        columns = obj.to_dict("list")
-        column_names = obj.columns.tolist()
-        column_types = {str(k): _ValueTypeId.infer_from_dtype(v, f"column '{str(k)}'") for (k, v) in obj.items()}
-    elif isinstance(obj, pd.Series):
-        # Handle series as columnar data
-        series_name = default_column_name if obj.name is None else obj.name
-        series_description = "series" if obj.name is None else f"series '{obj.name}'"
-
-        # Extract the column metadata from the series
-        try:
-            column_metadata = {series_name: obj.spotfire_column_metadata}
-        except AttributeError:
-            pass
-
-        columns = {series_name: obj.tolist()}
-        column_names = [series_name]
-        column_types = {series_name: _ValueTypeId.infer_from_dtype(obj, series_description)}
-    elif isinstance(obj, dict):
-        # Handle dicts of lists as tabular data
-        if not all(isinstance(x, list) for x in obj.values()):
-            raise SBDFError("obj is not a dict of lists")
-        if len({str(x) for x in obj.keys()}) != len(obj):
-            raise SBDFError("obj does not have unique column names")
-        column_metadata = {col: {} for col in obj.keys()}
-        columns = obj
-        column_names = obj.keys()
-        column_types = {str(k): _ValueTypeId.infer_from_type(v, f"column '{str(k)}'") for (k, v) in obj.items()}
-    elif isinstance(obj, (str, bytes, bytearray)):
-        # Handle strings and bytes as scalar data
-        column_metadata[default_column_name] = {}
-        columns = {default_column_name: [obj]}
-        column_names = [default_column_name]
-        column_types = {default_column_name: _ValueTypeId.infer_from_type([obj], "value")}
-    elif isinstance(obj, collections.abc.Iterable):
-        # Handle all iterable items as columnar data
-        column_metadata[default_column_name] = {}
-        columns = {default_column_name: list(obj)}
-        column_names = [default_column_name]
-        column_types = {default_column_name: _ValueTypeId.infer_from_type(list(obj), "list")}
-    else:
-        # Handle various image formats
-        if matplotlib is not None and isinstance(obj, matplotlib.figure.Figure):
-            obj = _pyplot_to_binary(obj)
-        elif seaborn is not None and isinstance(obj, seaborn.axisgrid.Grid):
-            obj = _seaborn_plot_to_binary(obj)
-        elif PIL is not None and isinstance(obj, PIL.Image.Image):
-            obj = _pil_image_to_binary(obj)
-
-        # If all else fails, treat this as scalar data
-        column_metadata[default_column_name] = {}
-        columns = {default_column_name: [obj]}
-        column_names = [default_column_name]
-        column_types = {default_column_name: _ValueTypeId.infer_from_type([obj], "value")}
-
-    return columns, column_names, column_types, table_metadata, column_metadata
-
-
-def _export_table_metadata(table_metadata: typing.Dict[str, typing.Any]) -> '_TableMetadata':
-    metadata = _Metadata()
-    if isinstance(table_metadata, dict):
-        for key, val in table_metadata.items():
-            obj_val = val if isinstance(val, list) else [val]
-            obj_valuetype = _ValueTypeId.infer_from_type(obj_val, "table metadata")
-            obj = _SbdfObject(obj_valuetype, obj_val)
-            metadata.add(key, obj)
-    tmeta = _TableMetadata(metadata)
-    return tmeta
-
-
-def _export_column_metadata(columns: typing.Dict[str, typing.List], column_names: typing.List[str],
-                            column_types: typing.Dict[str, '_ValueTypeId'],
-                            column_metadata: typing.Dict[str, typing.Dict[str, typing.Any]],
-                            tmeta: '_TableMetadata') -> int:
-    row_count = None
-    for colname in column_names:
-        metadata = _Metadata()
-        if isinstance(column_metadata, dict):
-            colmeta = column_metadata.get(colname, {})
-            if not isinstance(colmeta, dict):
-                raise SBDFError("column_metadata is not a dict of dicts")
-            for key, val in colmeta.items():
-                obj_val = val if isinstance(val, list) else [val]
-                obj_valuetype = _ValueTypeId.infer_from_type(obj_val, "column metadata")
-                obj = _SbdfObject(obj_valuetype, obj_val)
-                metadata.add(key, obj)
-        if row_count is None:
-            row_count = len(columns[colname])
-        else:
-            if row_count != len(columns[colname]):
-                raise SBDFError(f"column '{colname}' has inconsistent column length")
-        _ColumnMetadata.set_values(metadata, colname, column_types.get(str(colname)))
-        tmeta.add(metadata)
-    return row_count
-
-
-def _export_table_slices(columns: typing.Dict[str, typing.List], column_names: typing.List[str],
-                         column_types: typing.Dict[str, '_ValueTypeId'], file: typing.BinaryIO,
-                         row_count: int, tmeta: '_TableMetadata') -> None:
-    max_rows_per_slice = max(10, 100000 // max(1, len(column_names)))
-    row_offset = 0
-    while row_offset < row_count:
-        slice_row_count = min(max_rows_per_slice, row_count - row_offset)
-        tslice = _TableSlice(tmeta)
-        for colname in column_names:
-            obj = _SbdfObject(column_types.get(str(colname)), columns[colname][row_offset:row_offset + slice_row_count])
-            cslice = _ColumnSlice(_ValueArray(_ValueArrayEncoding.PLAIN_ARRAY, obj))
-            invalid = [pd.isnull(x) for x in obj.data]
-            if any(invalid):
-                obj_vt = _ValueType(column_types.get(str(colname)))
-                obj.data = [obj_vt.missing_value() if missing else val for val, missing in zip(obj.data, invalid)]
-                obj_empty = _SbdfObject(_ValueTypeId.BOOL, invalid)
-                va_empty = _ValueArray(_ValueArrayEncoding.BIT_ARRAY, obj_empty)
-                cslice.add_property(_ColumnSlice.ValueProperty_IsInvalid, va_empty)
-            tslice.add(cslice)
-        tslice.write(file)
-        row_offset += slice_row_count
-    _TableSlice.write_end(file)
-
-
-# Exceptions
-
-class SBDFError(Exception):
-    """An exception that is raised to indicate a problem during import or export of SBDF files."""
-
-
-# File Headers
-
-class _FileHeader:
-    Major_Version = 1
-    Minor_Version = 0
-    Version_String = "1.0"
-
-    @staticmethod
-    def write(file: typing.BinaryIO) -> None:
-        """writes the current sbdf fileheader to file"""
-        _section_write(file, _SectionTypeId.FILEHEADER)
-        _write_int8(file, _FileHeader.Major_Version)
-        _write_int8(file, _FileHeader.Minor_Version)
-
-    @staticmethod
-    def read(file: typing.BinaryIO) -> typing.Tuple[int, int]:
-        """reads the sbdf fileheader from file"""
-        _section_expect(file, _SectionTypeId.FILEHEADER)
-        major = _read_int8(file)
-        minor = _read_int8(file)
-        return major, minor
-
-
-# Column Slices
-
-class _ColumnSlice:
-    ValueProperty_IsInvalid = "IsInvalid"
-    ValueProperty_ErrorCode = "ErrorCode"
-    ValueProperty_ReplacedValue = "HasReplacedValue"
-
-    def __init__(self, values: '_ValueArray') -> None:
-        """creates a column slice and stores a reference to the valuearray in it"""
-        self.values = values
-        self.property_names = []
-        self.property_values = []
-
-    def __repr__(self) -> str:
-        return f"<{_utils.type_name(type(self))} object: {self.values!r}>"
-
-    def add_property(self, name: str, values: '_ValueArray') -> None:
-        """stores a named value property reference in the given column slice"""
-        if name in self.property_names:
-            raise SBDFError("the property with the given name already exists")
-        self.property_names.append(name)
-        self.property_values.append(values)
-
-    def get_property(self, name: str) -> typing.Optional['_ValueArray']:
-        """gets a value property reference with the given name"""
-        if name in self.property_names:
-            index = self.property_names.index(name)
-            return self.property_values[index]
-        return None
-
-    def get_property_count(self) -> int:
-        """gets the number of value properties in the column slice"""
-        return len(self.property_names)
-
-    def get_row_count(self) -> int:
-        """gets the number of rows of the values in the column slice"""
-        return self.values.row_count()
-
-    def write(self, file: typing.BinaryIO) -> None:
-        """writes a value array to file"""
-        _section_write(file, _SectionTypeId.COLUMNSLICE)
-        self.values.write(file)
-        _write_int32(file, self.get_property_count())
-        for i in range(self.get_property_count()):
-            _write_string(file, self.property_names[i])
-            self.property_values[i].write(file)
-
-    @classmethod
-    def read(cls, file: typing.BinaryIO) -> '_ColumnSlice':
-        """reads a value array from file"""
-        _section_expect(file, _SectionTypeId.COLUMNSLICE)
-        values = _ValueArray.read(file)
-        cslice = cls(values)
-        val = _read_int32(file)
-        for _ in range(val):
-            name = _read_string(file)
-            prop = _ValueArray.read(file)
-            cslice.add_property(name, prop)
-        return cslice
-
-    @classmethod
-    def skip(cls, file: typing.BinaryIO) -> None:
-        """skips a value array in the file"""
-        raise NotImplementedError  # sbdf_cs_skip
-
-
-# Table Slices
-
-class _TableSlice:
-    def __init__(self, table_metadata: '_TableMetadata') -> None:
-        """creates a table slice, storing a reference to the table metadata"""
-        self.table_metadata = table_metadata
-        self.columns = []
-
-    def __repr__(self) -> str:
-        return f"<{_utils.type_name(type(self))} object: {self.columns!r}>"
-
-    def column_count(self) -> int:
-        """get the number of columns in the table slice"""
-        return len(self.columns)
-
-    def add(self, column_slice: _ColumnSlice) -> None:
-        """adds a column slice reference to the table slice"""
-        self.columns.append(column_slice)
-
-    def write(self, file: typing.BinaryIO) -> None:
-        """writes a table slice to file"""
-        _section_write(file, _SectionTypeId.TABLESLICE)
-        no_columns = self.column_count()
-        _write_int32(file, no_columns)
-        for i in range(no_columns):
-            self.columns[i].write(file)
-
-    @staticmethod
-    def write_end(file: typing.BinaryIO) -> None:
-        """writes the end-of-table marker to the file"""
-        _section_write(file, _SectionTypeId.TABLEEND)
-
-    @classmethod
-    def read(cls, file: typing.BinaryIO, table_metadata: '_TableMetadata',
-             subset: typing.Optional[typing.List[bool]]) -> typing.Optional['_TableSlice']:
-        """reads a table slice from file. returns None when the end of the table is reached"""
-        val = _section_read(file)
-        if val == _SectionTypeId.TABLEEND:
-            return None
-        if val != _SectionTypeId.TABLESLICE:
-            raise SBDFError("unexpected section id")
-        column_count = _read_int32(file)
-        if column_count < 0:
-            raise SBDFError("the number of elements is incorrect")
-        if column_count != table_metadata.column_count():
-            raise SBDFError("the number of the columnslice doesn't match the number of the columns of the metadata")
-        tslice = cls(table_metadata)
-        for i in range(column_count):
-            if subset is None or subset[i]:
-                tslice.add(_ColumnSlice.read(file))
-            else:
-                _ColumnSlice.skip(file)
-        return tslice
-
-    @classmethod
-    def skip(cls, file: typing.BinaryIO, table_metadata: '_TableMetadata') -> None:
-        """skips a table slice in file"""
-        subset = []
-        for _ in range(table_metadata.column_count()):
-            subset.append(False)
-        cls.read(file, table_metadata, subset)
-
-
-# Column Metadata
-
-class _ColumnMetadata:
-    Property_Name = "Name"
-    Property_DataType = "DataType"
-
-    @staticmethod
-    def set_values(metadata: '_Metadata', column_name: str, data_type) -> None:
-        """sets the column metadata values name and data type for the previously allocated metadata head"""
-        metadata.add_str(_ColumnMetadata.Property_Name, column_name)
-        obj = _ValueType(data_type).as_sbdfobject()
-        metadata.add(_ColumnMetadata.Property_DataType, obj)
-
-    @staticmethod
-    def get_name(metadata: '_Metadata') -> str:
-        """gets the name of the column metadata"""
-        name = metadata.get(_ColumnMetadata.Property_Name)
-        if name.valuetype != _ValueTypeId.STRING:
-            raise SBDFError("the metadata is incorrect")
-        return name.data[0]
-
-    @staticmethod
-    def get_type(metadata: '_Metadata') -> '_ValueTypeId':
-        """gets the value type of the column metadata"""
-        obj = metadata.get(_ColumnMetadata.Property_DataType)
-        if obj.valuetype != _ValueTypeId.BINARY or obj.get_count() != 1:
-            raise SBDFError("the metadata is incorrect")
-        return _ValueTypeId(obj.data[0][0])
-
-
-# Table Metadata
-
-class _TableMetadata:
-    def __init__(self, table_metadata: '_Metadata') -> None:
-        """creates the table metadata, storing a copy the given table information"""
-        self.table_metadata = table_metadata
-        self.table_metadata.set_immutable()
-        self.column_metadata = []
-
-    def __repr__(self) -> str:
-        return f"<{_utils.type_name(type(self))} object: {self.table_metadata!r} {self.column_metadata!r}>"
-
-    def column_count(self) -> int:
-        """return the number of columns in this table"""
-        return len(self.column_metadata)
-
-    def add(self, column_metadata: '_Metadata') -> None:
-        """adds column metadata to the table metadata"""
-        self.column_metadata.append(column_metadata)
-
-    def write(self, file: typing.BinaryIO) -> None:
-        """writes table metadata to file"""
-        _section_write(file, _SectionTypeId.TABLEMETADATA)
-        _write_int32(file, self.table_metadata.count())
-        for i in range(self.table_metadata.count()):
-            _write_string(file, self.table_metadata.names[i])
-            _write_int8(file, self.table_metadata.values[i].valuetype)
-            _write_optional_value(file, self.table_metadata.values[i])
-            _write_optional_value(file, self.table_metadata.default_values[i])
-        _write_int32(file, self.column_count())
-        # fold duplicate values
-        cm_default = {}
-        cm_types = {}
-        for cmeta in self.column_metadata:
-            for i in range(cmeta.count()):
-                cm_name = cmeta.names[i]
-                if cm_name in cm_default and cm_default[cm_name] != cmeta.default_values[i]:
-                    raise SBDFError("the metadata is incorrect")
-                cm_default[cm_name] = cmeta.default_values[i]
-                if cmeta.values[i] is not None:
-                    cm_type = cmeta.values[i].valuetype
-                else:
-                    cm_type = cmeta.default_values[i].valuetype
-                if cm_name in cm_types and cm_types[cm_name] != cm_type:
-                    raise SBDFError("the metadata is incorrect")
-                cm_types[cm_name] = cm_type
-        _write_int32(file, len(cm_default))
-        # write names, data types, and default values
-        for i in sorted(cm_default):
-            _write_string(file, i)
-            _ValueType(cm_types[i]).write(file)
-            _write_optional_value(file, cm_default[i])
-        # write column values
-        for cmeta in self.column_metadata:
-            for i in sorted(cm_default):
-                val = cmeta.get(i)
-                _write_optional_value(file, val)
-
-    @classmethod
-    def read(cls, file: typing.BinaryIO) -> '_TableMetadata':
-        """reads table metadata from file"""
-        _section_expect(file, _SectionTypeId.TABLEMETADATA)
-        metadata_count = _read_int32(file)
-        if metadata_count < 0:
-            raise SBDFError("the number of elements is incorrect")
-        metadata = _Metadata()
-        for _ in range(metadata_count):
-            name = _read_string(file)
-            valtype = _ValueType.read(file)
-            value_present = _read_int8(file)
-            value = _SbdfObject.read(file, valtype) if value_present else None
-            value_present = _read_int8(file)
-            default_value = _SbdfObject.read(file, valtype) if value_present else None
-            metadata.add(name, value, default_value)
-        tmeta = _TableMetadata(metadata)
-
-        column_count = _read_int32(file)
-        metadata_count = _read_int32(file)
-        md_name = []
-        md_type = []
-        md_default = []
-        for i in range(metadata_count):
-            md_name.append(_read_string(file))
-            md_type.append(_ValueType.read(file))
-            value_present = _read_int8(file)
-            md_default.append(_SbdfObject.read(file, md_type[i]) if value_present else None)
-        for i in range(column_count):
-            metadata = _Metadata()
-            for j in range(metadata_count):
-                value_present = _read_int8(file)
-                if value_present:
-                    value = _SbdfObject.read(file, md_type[j])
-                    metadata.add(md_name[j], value, md_default[j])
-            tmeta.add(metadata)
-        return tmeta
-
-
-# Metadata
-
-class _Metadata:
-    def __init__(self) -> None:
-        """creates an empty metadata structure"""
-        self.modifiable = True
-        self.names = []
-        self.values = []
-        self.default_values = []
-
-    def __repr__(self) -> str:
-        return f"<{_utils.type_name(type(self))} object: {self.names!r} -> {self.values!r}>"
-
-    def add_str(self, name: str, value: str, default_value: str = None) -> None:
-        """adds a named string metadata value and default value to out"""
-        val = _SbdfObject(_ValueTypeId.STRING, [str(value)])
-        if default_value is not None:
-            default = _SbdfObject(_ValueTypeId.STRING, [str(default_value)])
-        else:
-            default = None
-        self.add(name, val, default)
-
-    def add_int(self, name: str, value: int, default_value: int = None) -> None:
-        """adds a named integer metadata value and default value to out"""
-        val = _SbdfObject(_ValueTypeId.INT, [value])
-        if default_value is not None:
-            default = _SbdfObject(_ValueTypeId.INT, [default_value])
-        else:
-            default = None
-        self.add(name, val, default)
-
-    def add(self, name: str, value: '_SbdfObject', default_value: '_SbdfObject' = None) -> None:
-        """adds a named metadata value and default value to out"""
-        if not self.modifiable:
-            raise SBDFError("the metadata is readonly and may not be modified")
-        if default_value is not None and value.valuetype != default_value.valuetype:
-            raise SBDFError("the valuetypes of the arguments must be equal")
-        if name in self.names:
-            raise SBDFError("the metadata with the given name already exists")
-        self.names.append(name)
-        self.values.append(value)
-        self.default_values.append(default_value)
-
-    def remove(self, name: str) -> None:
-        """removes the named metadata value from out"""
-        if not self.modifiable:
-            raise SBDFError("the metadata is readonly and may not be modified")
-        if name in self.names:
-            index = self.names.index(name)
-            del self.names[index]
-            del self.values[index]
-            del self.default_values[index]
-
-    def get(self, name: str) -> typing.Optional['_SbdfObject']:
-        """gets a copy of the named metadata value"""
-        if name in self.names:
-            index = self.names.index(name)
-            return self.values[index]
-        return None
-
-    def get_default(self, name: str) -> typing.Optional['_SbdfObject']:
-        """gets a copy of the named default metadata value"""
-        if name in self.names:
-            index = self.names.index(name)
-            return self.default_values[index]
-        return None
-
-    def count(self) -> int:
-        """returns the number of metadata entries pointed to by head"""
-        return len(self.names)
-
-    def exists(self, name: str) -> bool:
-        """returns True if the named metadata exists. False is returned if the metadata doesn't exist"""
-        return name in self.names
-
-    def set_immutable(self) -> None:
-        """sets the metadata immutable so that it may not be modified by subsequent operations"""
-        self.modifiable = False
-
-
-# Objects
-
-class _SbdfObject:
-    def __init__(self, valuetype: '_ValueTypeId', data: typing.List) -> None:
-        self.valuetype = valuetype
-        self.data = data
-
-    def __repr__(self) -> str:
-        return f"<{_utils.type_name(type(self))} object ({self.valuetype!r}): {self.data!r}>"
-
-    def get_count(self) -> int:
-        """get the number of items in the object"""
-        return len(self.data)
-
-    def write_array(self, file: typing.BinaryIO) -> None:
-        """writes the array information to the specified file. valuetype information is not written"""
-        count = len(self.data)
-        _write_int32(file, count)
-        self._write_n(file, count, True)
-
-    def write(self, file: typing.BinaryIO) -> None:
-        """writes the object to the specified file. valuetype information is not written"""
-        self._write_n(file, 1, False)
-
-    def _write_n(self, file: typing.BinaryIO, n: int, packed: bool) -> None:
-        valtype = _ValueType(self.valuetype)
-        if valtype.is_array():
-            byte_size = 0
-            if packed:
-                saved_bytes = []
-                for i in range(n):
-                    saved_bytes.append(valtype.to_bytes(self.data[i]))
-                    length = len(saved_bytes[i])
-                    byte_size += _get_7bit_packed_length(length) + length
-                _write_int32(file, byte_size)
-                for i in range(n):
-                    length = len(saved_bytes[i])
-                    _write_7bit_packed_int32(file, length)
-                    if length:
-                        _write_bytes(file, saved_bytes[i])
-            else:
-                for i in range(n):
-                    valtype_bytes = valtype.to_bytes(self.data[i])
-                    length = len(valtype_bytes)
-                    _write_int32(file, length)
-                    if length:
-                        _write_bytes(file, valtype_bytes)
-        else:
-            size = valtype.get_packed_size()
-            if size is None:
-                raise SBDFError("unknown typeid")
-            for i in range(n):
-                valtype_bytes = valtype.to_bytes(self.data[i])
-                _write_bytes(file, valtype_bytes)
-
-    @classmethod
-    def read_array(cls, file: typing.BinaryIO, valuetype: '_ValueType') -> '_SbdfObject':
-        """reads an array object with the given value type from the specified file"""
-        count = _read_int32(file)
-        return cls._read_n(file, count, valuetype, True)
-
-    @classmethod
-    def read(cls, file: typing.BinaryIO, valuetype: '_ValueType') -> '_SbdfObject':
-        """reads an object with the given valuetype from the file."""
-        return cls._read_n(file, 1, valuetype, False)
-
-    @classmethod
-    def _read_n(cls, file: typing.BinaryIO, n: int, valuetype: '_ValueType', packed: bool) -> '_SbdfObject':
-        data = []
-        if valuetype.is_array():
-            # read byte size and ignore it
-            if packed:
-                _read_int32(file)
-            for _ in range(n):
-                if packed:
-                    length = _read_7bit_packed_int32(file)
-                else:
-                    length = _read_int32(file)
-                if length < 0:
-                    raise SBDFError("the number of elements is incorrect")
-                dest = _read_bytes(file, length)
-                data.append(valuetype.to_python(dest))
-        else:
-            size = valuetype.get_packed_size()
-            if size is None:
-                raise SBDFError("unknown typeid")
-            for _ in range(n):
-                dest = _read_bytes(file, size)
-                data.append(valuetype.to_python(dest))
-        return cls(valuetype.type_id, data)
-
-    @classmethod
-    def skip_array(cls, file: typing.BinaryIO, valuetype: '_ValueType') -> None:
-        """skips an array with the given valuetype"""
-        raise NotImplementedError  # sbdf_obj_skip_arr
-
-    @classmethod
-    def skip(cls, file: typing.BinaryIO, valuetype: '_ValueType') -> None:
-        """skips an object with the given valuetype"""
-        raise NotImplementedError  # sbdf_obj_skip
-
-
-# Value arrays
-
-class _ValueArrayEncoding(enum.IntEnum):
-    PLAIN_ARRAY = 0x1
-    RUN_LENGTH = 0x2
-    BIT_ARRAY = 0x3
-
-
-class _ValueArray:
-    def __init__(self, array_encoding: _ValueArrayEncoding, array: typing.Optional[_SbdfObject]) -> None:
-        """creates a value array from the specified values"""
-        self.encoding = _ValueArrayEncoding(array_encoding)
-        self.val1 = None
-        self.obj1 = None
-        self.obj2 = None
-        self.valuetype = None
-        if array is None:
-            pass
-        elif array_encoding == _ValueArrayEncoding.PLAIN_ARRAY:
-            self._create_plain(array)
-        elif array_encoding == _ValueArrayEncoding.RUN_LENGTH:
-            self._create_rle(array)
-        elif array_encoding == _ValueArrayEncoding.BIT_ARRAY:
-            self._create_bit(array)
-        else:
-            raise SBDFError("unknown valuearray encoding")
-
-    def _create_plain(self, array: _SbdfObject) -> None:
-        self.valuetype = array.valuetype
-        self.obj1 = array
-
-    def _create_rle(self, array: _SbdfObject) -> None:
-        raise NotImplementedError  # sbdf_va_create_rle
-
-    def _create_bit(self, array: _SbdfObject) -> None:
-        self.valuetype = _ValueTypeId.BOOL
-        self.val1 = array.get_count()
-        bits = bitstring.BitArray(array.data)
-        while bits.len % 8 != 0:
-            bits.append('0b0')
-        self.obj1 = _SbdfObject(_ValueTypeId.BINARY, [bits.bytes])
-
-    def __repr__(self) -> str:
-        if self.encoding == _ValueArrayEncoding.PLAIN_ARRAY:
-            arr = self.obj1
-        elif self.encoding == _ValueArrayEncoding.RUN_LENGTH:
-            arr = "..."
-        elif self.encoding == _ValueArrayEncoding.BIT_ARRAY:
-            arr = self.obj1.data[0]
-        else:
-            arr = "unknown encoding"
-        return f"<{_utils.type_name(type(self))} object ({self.encoding!r}): {arr!r}>"
-
-    def get_values(self) -> _SbdfObject:
-        """extracts the values from the array"""
-        if self.encoding == _ValueArrayEncoding.PLAIN_ARRAY:
-            return self.obj1
-        if self.encoding == _ValueArrayEncoding.RUN_LENGTH:
-            return self._get_rle()
-        if self.encoding == _ValueArrayEncoding.BIT_ARRAY:
-            return self._get_bit()
-        raise SBDFError("unknown valuearray encoding")
-
-    def _get_rle(self) -> _SbdfObject:
-        raise NotImplementedError  # sbdf_get_rle_values
-
-    def _get_bit(self) -> _SbdfObject:
-        obj = _SbdfObject(_ValueTypeId.BOOL, [])
-        bits = bitstring.BitArray(bytes=self.obj1.data[0], length=self.val1)
-        for i in bits:
-            obj.data.append(i)
-        return obj
-
-    def row_count(self) -> int:
-        """returns the number of rows stored in the value array"""
-        if self.encoding == _ValueArrayEncoding.PLAIN_ARRAY:
-            return self.obj1.get_count()
-        if self.encoding == _ValueArrayEncoding.RUN_LENGTH:
-            return self.val1
-        if self.encoding == _ValueArrayEncoding.BIT_ARRAY:
-            return self.val1
-        raise SBDFError("unknown valuearray encoding")
-
-    def write(self, file: typing.BinaryIO) -> None:
-        """writes the value array to the current file position"""
-        _write_int8(file, self.encoding)
-        _ValueType(self.valuetype).write(file)
-        if self.encoding == _ValueArrayEncoding.PLAIN_ARRAY:
-            self.obj1.write_array(file)
-        elif self.encoding == _ValueArrayEncoding.RUN_LENGTH:
-            _write_int32(file, self.val1)
-            self.obj1.write_array(file)
-            self.obj2.write_array(file)
-        elif self.encoding == _ValueArrayEncoding.BIT_ARRAY:
-            _write_int32(file, self.val1)
-            _write_bytes(file, self.obj1.data[0])
-        else:
-            raise SBDFError("unknown valuearray encoding")
-
-    @classmethod
-    def read(cls, file: typing.BinaryIO) -> '_ValueArray':
-        """reads the value array from the current file position"""
-        encoding = _read_int8(file)
-        valtype = _ValueType.read(file)
-        handle = cls(_ValueArrayEncoding(encoding), None)
-        handle.valuetype = valtype
-        if encoding == _ValueArrayEncoding.PLAIN_ARRAY:
-            handle.obj1 = _SbdfObject.read_array(file, valtype)
-        elif encoding == _ValueArrayEncoding.RUN_LENGTH:
-            handle.val1 = _read_int32(file)
-            handle.obj1 = _SbdfObject.read_array(file, _ValueType(_ValueTypeId.INTERNAL_BYTE))
-            handle.obj2 = _SbdfObject.read_array(file, valtype)
-        elif encoding == _ValueArrayEncoding.BIT_ARRAY:
-            val = _read_int32(file)
-            handle.val1 = val
-            packed_size = val // 8 + (1 if val % 8 else 0)
-            bits = _read_bytes(file, packed_size)
-            handle.obj1 = _SbdfObject(_ValueTypeId.BINARY, [bits])
-        else:
-            raise SBDFError("unknown valuearray encoding")
-        return handle
-
-    @classmethod
-    def skip(cls, file: typing.BinaryIO) -> None:
-        """skips the value array at the current file position"""
-        encoding = _read_int8(file)
-        valtype = _ValueType.read(file)
-        if encoding == _ValueArrayEncoding.PLAIN_ARRAY:
-            _SbdfObject.skip_array(file, valtype)
-        elif encoding == _ValueArrayEncoding.RUN_LENGTH:
-            _SbdfObject.skip_array(file, _ValueType(_ValueTypeId.INTERNAL_BYTE))
-            _SbdfObject.skip_array(file, valtype)
-        elif encoding == _ValueArrayEncoding.BIT_ARRAY:
-            val = _read_int32(file)
-            packed_size = val // 8 + (1 if val % 8 else 0)
-            file.seek(packed_size, 1)
-        else:
-            raise SBDFError("unknown valuearray encoding")
-
-
-# Value types
-
-class _ValueTypeId(enum.IntEnum):
-    UNKNOWN = 0x00
-    BOOL = 0x01      # C type is char
-    INT = 0x02       # C type is 32-bit int
-    LONG = 0x03      # C type is 64-bit int
-    FLOAT = 0x04     # C type is float
-    DOUBLE = 0x05    # C type is double
-    DATETIME = 0x06  # C representation is milliseconds since 01/01/01, 00:00:00, stored in a 64-bit int
-    DATE = 0x07      # C representation is milliseconds since 01/01/01, 00:00:00, stored in a 64-bit int
-    TIME = 0x08      # C representation is milliseconds since 01/01/01, 00:00:00, stored in a 64-bit int
-    TIMESPAN = 0x09  # C representation is milliseconds, stored in a 64-bit int
-    STRING = 0x0a    # C representation is char-ptr
-    BINARY = 0x0c    # C representation is void-ptr
-    DECIMAL = 0x0d   # C representation is IEEE754 decimal128 Binary Integer Decimals
-    INTERNAL_BYTE = 0xfe
-
-    def to_typename_string(self) -> str:
-        """convert this valuetype id to the type name used by Spotfire"""
-        return {
-            _ValueTypeId.BOOL: "Boolean",
-            _ValueTypeId.INT: "Integer",
-            _ValueTypeId.LONG: "LongInteger",
-            _ValueTypeId.FLOAT: "SingleReal",
-            _ValueTypeId.DOUBLE: "Real",
-            _ValueTypeId.DATETIME: "DateTime",
-            _ValueTypeId.DATE: "Date",
-            _ValueTypeId.TIME: "Time",
-            _ValueTypeId.TIMESPAN: "TimeSpan",
-            _ValueTypeId.STRING: "String",
-            _ValueTypeId.BINARY: "Binary",
-            _ValueTypeId.DECIMAL: "Currency"
-        }.get(self, "unknown")
-
-    @staticmethod
-    def from_typename_string(typename: str) -> '_ValueTypeId':
-        """convert the type name used by Spotfire to a valuetype id"""
-        return {
-            "Boolean": _ValueTypeId.BOOL,
-            "Integer": _ValueTypeId.INT,
-            "LongInteger": _ValueTypeId.LONG,
-            "SingleReal": _ValueTypeId.FLOAT,
-            "Real": _ValueTypeId.DOUBLE,
-            "DateTime": _ValueTypeId.DATETIME,
-            "Date": _ValueTypeId.DATE,
-            "Time": _ValueTypeId.TIME,
-            "TimeSpan": _ValueTypeId.TIMESPAN,
-            "String": _ValueTypeId.STRING,
-            "Binary": _ValueTypeId.BINARY,
-            "Currency": _ValueTypeId.DECIMAL
-        }.get(typename)
-
-    def to_dtype_name(self) -> str:
-        """convert this valuetype id to the dtype name used by Pandas"""
-        return {
-            _ValueTypeId.INT: "Int32",
-            _ValueTypeId.LONG: "Int64",
-            _ValueTypeId.FLOAT: "float32",
-            _ValueTypeId.DOUBLE: "float64"
-        }.get(self, "object")
-
-    @staticmethod
-    def infer_from_type(values, value_description: str) -> '_ValueTypeId':
-        """determine the proper valuetype id from the Python types in a column"""
-        # Remove any None (or other none-ish things) from values
-        vals = [x for x in values if not pd.isnull(x)]
-        # Check if any values remain
-        if not vals:
-            raise SBDFError(f"cannot determine type for {value_description}; all values are missing")
-        # Check to make sure only one type remains
-        vals_type = type(vals[0])
-        if not all(isinstance(i, vals_type) for i in vals):
-            raise SBDFError(f"types in {value_description} do not match")
-        # Determine the right type id
-        typeid = {
-            bool: _ValueTypeId.BOOL,
-            np.int32: _ValueTypeId.INT,
-            int: _ValueTypeId.LONG,
-            np.int64: _ValueTypeId.LONG,
-            np.float32: _ValueTypeId.FLOAT,
-            float: _ValueTypeId.DOUBLE,
-            np.float64: _ValueTypeId.DOUBLE,
-            datetime.datetime: _ValueTypeId.DATETIME,
-            pd.Timestamp: _ValueTypeId.DATETIME,
-            datetime.date: _ValueTypeId.DATE,
-            datetime.time: _ValueTypeId.TIME,
-            datetime.timedelta: _ValueTypeId.TIMESPAN,
-            pd.Timedelta: _ValueTypeId.TIMESPAN,
-            str: _ValueTypeId.STRING,
-            bytes: _ValueTypeId.BINARY,
-            decimal.Decimal: _ValueTypeId.DECIMAL,
-        }.get(vals_type, None)
-        if typeid is None:
-            raise SBDFError(f"unknown type '{_utils.type_name(vals_type)}' in {value_description}")
-        return typeid
-
-    @staticmethod
-    def infer_from_dtype(series: pd.Series, series_description: str) -> '_ValueTypeId':
-        """determine the proper valuetype id from the Pandas dtype of a series"""
-        # use explicitly set type info if present
-        sbdf_type = None
-        if 'spotfire_type' in series.attrs:
-            sbdf_type = _ValueTypeId.from_typename_string(series.attrs['spotfire_type'])
-            if sbdf_type == _ValueTypeId.INT:
-                int32_info = np.iinfo(np.int32)
-                for i in series:
-                    if isinstance(i, int) and not int32_info.min < i < int32_info.max:
-                        warnings.warn(
-                            f"values in {series_description} do not fit in type 'Integer'; promoting type to "
-                            f"'LongInteger'")
-                        sbdf_type = _ValueTypeId.LONG
-                        break
-        if sbdf_type:
-            return sbdf_type
-
-        # otherwise, infer the type info
-        dtype = series.dtype.name
-        if dtype == "object":
-            return _ValueTypeId.infer_from_type(series.tolist(), series_description)
-        if dtype == "category":
-            return _ValueTypeId.infer_from_dtype(series.astype(series.cat.categories.dtype), series_description)
-        typeid = {
-            "bool": _ValueTypeId.BOOL,
-            "boolean": _ValueTypeId.BOOL,
-            "int32": _ValueTypeId.INT,
-            "Int32": _ValueTypeId.INT,
-            "int64": _ValueTypeId.LONG,
-            "Int64": _ValueTypeId.LONG,
-            "float32": _ValueTypeId.FLOAT,
-            "Float32": _ValueTypeId.FLOAT,
-            "float64": _ValueTypeId.DOUBLE,
-            "Float64": _ValueTypeId.DOUBLE,
-            "datetime64[ns]": _ValueTypeId.DATETIME,
-            "timedelta64[ns]": _ValueTypeId.TIMESPAN,
-            "string": _ValueTypeId.STRING,
-        }.get(dtype, None)
-        if typeid is None:
-            raise SBDFError(f"unknown dtype '{dtype}' in {series_description}")
-        return typeid
-
-
-class _ValueType:
-    def __init__(self, type_id: int) -> None:
-        self.type_id = _ValueTypeId(type_id)
-
-    def __repr__(self) -> str:
-        return str(self.type_id)
-
-    def __cmp__(self, other: '_ValueType') -> int:
-        return self.type_id - other.type_id
-
-    def write(self, file: typing.BinaryIO) -> None:
-        """writes a valuetype to the current file position"""
-        _write_int8(file, self.type_id)
-
-    @classmethod
-    def read(cls, file: typing.BinaryIO) -> '_ValueType':
-        """reads a valuetype from the current file position"""
-        return cls(_read_int8(file))
-
-    def is_array(self) -> bool:
-        """determines if this valuetype is an array type index"""
-        return self.type_id in (_ValueTypeId.STRING, _ValueTypeId.BINARY)
-
-    def get_packed_size(self) -> int:
-        """returns the packed byte size (on disk) of a valuetype"""
-        return {
-            _ValueTypeId.BOOL: 1,
-            _ValueTypeId.INT: 4,
-            _ValueTypeId.LONG: 8,
-            _ValueTypeId.FLOAT: 4,
-            _ValueTypeId.DOUBLE: 8,
-            _ValueTypeId.DATETIME: 8,
-            _ValueTypeId.DATE: 8,
-            _ValueTypeId.TIME: 8,
-            _ValueTypeId.TIMESPAN: 8,
-            _ValueTypeId.STRING: 0,  # size is dynamic
-            _ValueTypeId.BINARY: 0,  # size is dynamic
-            _ValueTypeId.DECIMAL: 16
-        }.get(self.type_id, None)
-
-    _DATETIME_EPOCH = datetime.datetime(1, 1, 1)
-    _DECIMAL_EXPONENT_BIAS = 12320
-
-    @staticmethod
-    def _to_python_bool(data: bytes) -> bool:
-        return struct.unpack("?", data)[0]
-
-    @staticmethod
-    def _to_python_int(data: bytes) -> int:
-        return struct.unpack("<i", data)[0]
-
-    @staticmethod
-    def _to_python_long(data: bytes) -> int:
-        return struct.unpack("<q", data)[0]
-
-    @staticmethod
-    def _to_python_float(data: bytes) -> float:
-        return struct.unpack("<f", data)[0]
-
-    @staticmethod
-    def _to_python_double(data: bytes) -> float:
-        return struct.unpack("<d", data)[0]
-
-    @staticmethod
-    def _to_python_datetime(data: bytes) -> datetime.datetime:
-        timestamp = struct.unpack("<q", data)[0]
-        return _ValueType._DATETIME_EPOCH + datetime.timedelta(milliseconds=timestamp)
-
-    @staticmethod
-    def _to_python_date(data: bytes) -> datetime.date:
-        timestamp = struct.unpack("<q", data)[0]
-        date = _ValueType._DATETIME_EPOCH + datetime.timedelta(milliseconds=timestamp)
-        return date.date()
-
-    @staticmethod
-    def _to_python_time(data: bytes) -> datetime.time:
-        timestamp = struct.unpack("<q", data)[0]
-        date = _ValueType._DATETIME_EPOCH + datetime.timedelta(milliseconds=timestamp)
-        return date.timetz()
-
-    @staticmethod
-    def _to_python_timespan(data: bytes) -> datetime.timedelta:
-        timespan = struct.unpack("<q", data)[0]
-        return datetime.timedelta(milliseconds=timespan)
-
-    @staticmethod
-    def _to_python_string(data: bytes) -> str:
-        return data.decode("utf-8")
-
-    @staticmethod
-    def _to_python_binary(data: bytes) -> bytes:
-        return data
-
-    @staticmethod
-    def _to_python_decimal(data: bytes) -> decimal.Decimal:
-        bits = bitstring.BitArray(bytes=data)
-        # pylint: disable=unbalanced-tuple-unpacking
-        coefficient, biased_exponent_bits_high, sign_bit, biased_exponent_bits_low = \
-            bits.unpack('uintle:96,pad:17,bits:7,bool,bits:7')
-        # un-bias the exponent
-        biased_exponent_bits = bitstring.BitArray('0b00')
-        biased_exponent_bits.append(biased_exponent_bits_high)
-        biased_exponent_bits.append(biased_exponent_bits_low)
-        exponent = biased_exponent_bits.uintle - _ValueType._DECIMAL_EXPONENT_BIAS
-        # break up the coefficient into its digits
-        digits = []
-        while coefficient != 0:
-            digits.insert(0, coefficient % 10)
-            coefficient //= 10
-        # construct the decimal value
-        return decimal.Decimal((1 if sign_bit else 0, tuple(digits), exponent))
-
-    def to_python(self, data: bytes) -> typing.Any:
-        """return a Python representation of the raw data"""
-        return getattr(self, "_to_python_" + self.type_id.name.lower(), lambda x: None)(data)
-
-    @staticmethod
-    def _to_bytes_bool(obj: bool) -> bytes:
-        return struct.pack("?", obj)
-
-    @staticmethod
-    def _to_bytes_int(obj: int) -> bytes:
-        return struct.pack("<i", obj)
-
-    @staticmethod
-    def _to_bytes_long(obj: int) -> bytes:
-        return struct.pack("<q", obj)
-
-    @staticmethod
-    def _to_bytes_float(obj: float) -> bytes:
-        return struct.pack("<f", obj)
-
-    @staticmethod
-    def _to_bytes_double(obj: float) -> bytes:
-        return struct.pack("<d", obj)
-
-    @staticmethod
-    def _to_bytes_datetime(obj: datetime.datetime) -> bytes:
-        if isinstance(obj, pd.Timestamp):
-            obj_dt = obj.to_pydatetime()
-        else:
-            obj_dt = obj
-        td_after_epoch = obj_dt - _ValueType._DATETIME_EPOCH
-        timespan = int(td_after_epoch / datetime.timedelta(milliseconds=1))
-        return struct.pack("<q", timespan)
-
-    @staticmethod
-    def _to_bytes_date(obj: datetime.date) -> bytes:
-        td_after_epoch = obj - _ValueType._DATETIME_EPOCH.date()
-        timespan = int(td_after_epoch / datetime.timedelta(milliseconds=1))
-        return struct.pack("<q", timespan)
-
-    @staticmethod
-    def _to_bytes_time(obj: datetime.time) -> bytes:
-        obj_td = datetime.datetime.combine(datetime.datetime.min, obj) - datetime.datetime.min
-        timestamp = obj_td // datetime.timedelta(milliseconds=1)
-        return struct.pack("<q", timestamp)
-
-    @staticmethod
-    def _to_bytes_timespan(obj: datetime.timedelta) -> bytes:
-        if isinstance(obj, pd.Timedelta):
-            obj_td = obj.to_pytimedelta()
-        elif pd.isnull(obj):
-            obj_td = datetime.timedelta(0)
-        else:
-            obj_td = obj
-        timespan = int(obj_td / datetime.timedelta(milliseconds=1))
-        return struct.pack("<q", timespan)
-
-    @staticmethod
-    def _to_bytes_string(obj: str) -> bytes:
-        return str(obj).encode("utf-8")
-
-    @staticmethod
-    def _to_bytes_binary(obj: bytes) -> bytes:
-        return obj
-
-    @staticmethod
-    def _to_bytes_decimal(obj: decimal.Decimal) -> bytes:
-        dec = obj.as_tuple()
-        # bias the exponent and convert it to a 14-bit value
-        biased_exponent = dec.exponent + _ValueType._DECIMAL_EXPONENT_BIAS
-        biased_exponent_bits = bitstring.pack('uintle:16', biased_exponent)
-        biased_exponent_bits_high = biased_exponent_bits[2:9]
-        biased_exponent_bits_low = biased_exponent_bits[9:16]
-        # combine the digits of the coefficient
-        coefficient = 0
-        for i in dec.digits:
-            coefficient *= 10
-            coefficient += i
-        # construct the binary128 value
-        return bitstring.pack('uintle:96,pad:17,bits:7,bool,bits:7', coefficient, biased_exponent_bits_high,
-                              dec.sign, biased_exponent_bits_low).bytes
-
-    def to_bytes(self, obj: typing.Any) -> bytes:
-        """return a SBDF representation of the Python objects"""
-        try:
-            return getattr(self, "_to_bytes_" + self.type_id.name.lower(), lambda x: None)(obj)
-        except (struct.error, bitstring.CreationError, UnicodeError) as exc:
-            raise SBDFError(f"cannot convert '{obj}' to Spotfire {self.type_id.to_typename_string()} \
-                        type; value is outside representable range") from exc
-        except (AttributeError, TypeError) as exc:
-            raise SBDFError(f"cannot convert '{obj}' to Spotfire {self.type_id.to_typename_string()} \
-                        type; incompatible types") from exc
-
-    def missing_value(self) -> typing.Any:
-        """return a missing value appropriate for the value type"""
-        val = None
-        if self.type_id == _ValueTypeId.BOOL:
-            val = False
-        elif self.type_id == _ValueTypeId.INT:
-            val = 0
-        elif self.type_id == _ValueTypeId.LONG:
-            val = 0
-        elif self.type_id == _ValueTypeId.FLOAT:
-            val = 0.0
-        elif self.type_id == _ValueTypeId.DOUBLE:
-            val = 0.0
-        elif self.type_id == _ValueTypeId.DATETIME:
-            val = self._DATETIME_EPOCH
-        elif self.type_id == _ValueTypeId.DATE:
-            val = self._DATETIME_EPOCH.date()
-        elif self.type_id == _ValueTypeId.TIME:
-            val = self._DATETIME_EPOCH.time()
-        elif self.type_id == _ValueTypeId.TIMESPAN:
-            val = datetime.timedelta(0)
-        elif self.type_id == _ValueTypeId.STRING:
-            val = ""
-        elif self.type_id == _ValueTypeId.BINARY:
-            val = b""
-        elif self.type_id == _ValueTypeId.DECIMAL:
-            val = decimal.Decimal()
-        return val
-
-    def as_sbdfobject(self) -> _SbdfObject:
-        """return an SbdfObject that describes this ValueType"""
-        return _SbdfObject(_ValueTypeId.BINARY, [struct.pack(">B", self.type_id)])
-
-
-# Sections
-
-class _SectionTypeId(enum.IntEnum):
-    # An unknown section type.
-    UNKNOWN = 0x0
-
-    # A file header section.
-    FILEHEADER = 0x1
-
-    # A table metadata section, marking the beginning of a complete table.
-    TABLEMETADATA = 0x2
-
-    # A table slice section.
-    TABLESLICE = 0x3
-
-    # A column slice section.
-    COLUMNSLICE = 0x4
-
-    # Marks the end of a complete data table.
-    TABLEEND = 0x5
-
-
-def _section_write(file: typing.BinaryIO, section_id: _SectionTypeId) -> None:
-    """writes the given section type id to file"""
-    _write_int8(file, 0xdf)
-    _write_int8(file, 0x5b)
-    _write_int8(file, section_id)
-
-
-def _section_read(file: typing.BinaryIO) -> int:
-    """reads the given section type id from file"""
-    val = _read_int8(file)
-    if val != 0xdf:
-        raise SBDFError("the SBDF magic number wasn't found")
-    val = _read_int8(file)
-    if val != 0x5b:
-        raise SBDFError("the SBDF magic number wasn't found")
-    return _read_int8(file)
-
-
-def _section_expect(file: typing.BinaryIO, section_id: _SectionTypeId) -> None:
-    """reads the section type id from file. raises error if different from passed id"""
-    val = _section_read(file)
-    if val != section_id:
-        raise SBDFError("unexpected section id")
-
-
-# Internals
-
-def _write_bytes(file: typing.BinaryIO, value: bytes) -> int:
-    if "b" not in file.mode:
-        raise SBDFError("file not opened as binary")
-    written = file.write(value)
-    if written != len(value):
-        raise SBDFError("i/o error")
-    return written
-
-
-def _read_bytes(file: typing.BinaryIO, n: int) -> bytes:
-    if "b" not in file.mode:
-        raise SBDFError("file not opened as binary")
-    bytes_read = file.read(n)
-    if len(bytes_read) != n:
-        raise SBDFError("i/o error")
-    return bytes_read
-
-
-def _write_int8(file: typing.BinaryIO, value: int) -> None:
-    to_write = struct.pack("B", value)
-    _write_bytes(file, to_write)
-
-
-def _read_int8(file: typing.BinaryIO) -> int:
-    bytes_read = _read_bytes(file, 1)
-    return struct.unpack("B", bytes_read)[0]
-
-
-def _write_int32(file: typing.BinaryIO, value: int) -> None:
-    to_write = struct.pack("<i", value)
-    _write_bytes(file, to_write)
-
-
-def _read_int32(file: typing.BinaryIO) -> int:
-    bytes_read = _read_bytes(file, 4)
-    return struct.unpack("<i", bytes_read)[0]
-
-
-def _write_string(file: typing.BinaryIO, string: str) -> None:
-    to_write = string.encode("utf-8")
-    _write_int32(file, len(to_write))
-    _write_bytes(file, to_write)
-
-
-def _read_string(file: typing.BinaryIO) -> str:
-    size = _read_int32(file)
-    if size < 0:
-        raise SBDFError("the number of elements is incorrect")
-    bytes_read = _read_bytes(file, size)
-    return bytes_read.decode("utf-8")
-
-
-def _skip_string(file: typing.BinaryIO) -> None:
-    size = _read_int32(file)
-    if size < 0:
-        raise SBDFError("the number of elements is incorrect")
-    file.seek(size, 1)
-
-
-def _write_7bit_packed_int32(file: typing.BinaryIO, value: int) -> None:
-    while True:
-        uch = bytearray(1)
-        uch[0] = value & 0x7f
-        if value > 0x7f:
-            uch[0] |= 0x80
-        if _write_bytes(file, uch) != 1:
-            raise SBDFError("i/o error")
-        if value > 0x7f:
-            value >>= 7
-        else:
-            break
-
-
-def _read_7bit_packed_int32(file: typing.BinaryIO) -> int:
-    value = 0
-    shift = 0
-    while True:
-        byte_read = _read_bytes(file, 1)
-        byte_as_int = byte_read[0]
-        value |= (byte_as_int & 0x7f) << shift
-        if (byte_as_int & 0x80) == 0x80:
-            shift += 7
-        else:
-            break
-    return value
-
-
-def _get_7bit_packed_length(value: int) -> int:
-    length = 5
-    if value < (1 << 7):
-        length = 1
-    elif value < (1 << 14):
-        length = 2
-    elif value < (1 << 21):
-        length = 3
-    elif value < (1 << 28):
-        length = 4
-    return length
-
-
-def _write_optional_value(file: typing.BinaryIO, value) -> None:
-    if value is None:
-        _write_int8(file, 0)
-    else:
-        _write_int8(file, 1)
-        value.write(file)
-
-
-if gpd is not None:
-    def _geo_data_frame_to_data_frame(gdf: gpd.GeoDataFrame,
-                                      table_metadata: typing.Dict[str, typing.Any],
-                                      column_metadata: typing.Dict[str, typing.Dict[str, typing.Any]]) -> \
-            pd.DataFrame:
-        if not isinstance(gdf.get("geometry")[0], shp_geom.BaseGeometry):
-            return gdf
-
-        # Save CRS before manipulating gdf, as geopandas >= 0.11.0 disallows fetching it
-        # after dropping the geometry column
-        saved_crs = gdf.crs
-
-        # Write geocoding column metadata
-        cols = ["XMin", "XMax", "YMin", "YMax", "XCenter", "YCenter", "Geometry"]
-        metacols = list(gdf.columns.drop('geometry'))
-        metacols.extend(cols)
-        for col in metacols:
-            if col not in column_metadata.keys():
-                column_metadata[col] = {}
-        for col in cols:
-            column_metadata[col]["MapChart.ColumnTypeId"] = col
-        column_metadata["Geometry"]["ContentType"] = "application/x-wkb"
-
-        # Convert to dataframe
-        geom = [shapely.wkb.dumps(x) for x in gdf["geometry"]]
-        geom_series = gdf["geometry"]
-        gdf = gdf.drop(columns="geometry")
-        names = gdf.keys()
-        dframe = pd.DataFrame(gdf.to_numpy(), columns=names)
-
-        # Add spotfire geocoding columns
-        dframe["Geometry"] = pd.Series(geom)
-        xmin, ymin, xmax, ymax = zip(*[x.bounds for x in geom_series])
-        xcen, ycen = zip(*[x.centroid.coords[:][0] for x in geom_series])
-        dframe["XMin"] = pd.Series(xmin)
-        dframe["XMax"] = pd.Series(xmax)
-        dframe["YMin"] = pd.Series(ymin)
-        dframe["YMax"] = pd.Series(ymax)
-        dframe["XCenter"] = pd.Series(xcen)
-        dframe["YCenter"] = pd.Series(ycen)
-
-        # Write geocoding table metadata
-        table_metadata["MapChart.IsGeocodingTable"] = True
-        if not table_metadata.get("MapChart.IsGeocodingEnabled"):
-            table_metadata["MapChart.IsGeocodingEnabled"] = True
-        if all(isinstance(x, shapely.geometry.Point) for x in geom_series):
-            table_metadata["MapChart.GeometryType"] = "Point"
-        elif all(isinstance(x, shapely.geometry.LineString) for x in geom_series) or \
-                all(isinstance(x, shapely.geometry.LinearRing) for x in geom_series):
-            table_metadata["MapChart.GeometryType"] = "Line"
-        elif all(isinstance(x, shapely.geometry.MultiLineString) for x in geom_series):
-            table_metadata["MapChart.GeometryType"] = "PolyLine"
-        elif all(isinstance(x, shapely.geometry.Polygon) for x in geom_series):
-            table_metadata["MapChart.GeometryType"] = "Polygon"
-        else:
-            raise SBDFError("cannot convert collections of Shapely objects")
-        if saved_crs is not None:
-            try:
-                table_metadata["MapChart.GeographicCrs"] = saved_crs.to_string()
-            except AttributeError:
-                # GeoPandas <= 0.6.3 compatibility
-                if saved_crs.startswith("+init="):
-                    saved_crs = saved_crs[6:]
-                table_metadata["MapChart.GeographicCrs"] = saved_crs
-        return dframe
-
-
-    def _data_frame_to_geo_data_frame(dframe: pd.DataFrame,
-                                      table_metadata: typing.Dict[str, typing.Any]) -> gpd.GeoDataFrame:
-        if not isinstance(dframe.get('Geometry')[0], bytes):
-            raise SBDFError("cannot convert to geoDataFrame")
-
-        # Convert to geodataframe
-        geom = [shapely.wkb.loads(x) for x in dframe["Geometry"]]
-        dframe = dframe.drop(columns="Geometry")
-        gdf = gpd.GeoDataFrame(dframe, geometry=geom)
-
-        # Decide what CRS to use
-        if "MapChart.GeographicCrs" in table_metadata.keys() and table_metadata["MapChart.GeographicCrs"] != "":
-            proj = table_metadata["MapChart.GeographicCrs"][0]
-            gdf.crs = proj
-            try:
-                # GeoPandas <= 0.6.3 compatibility
-                if not gdf.crs.startswith("+init="):
-                    gdf.crs = "+init=" + proj
-            except AttributeError:
-                pass
-
-        return gdf
-
-if matplotlib is not None:
-    def _pyplot_to_binary(fig: matplotlib.figure.Figure) -> bytes:
-        fig.set_canvas(matplotlib.pyplot.gcf().canvas)
-        with tempfile.NamedTemporaryFile(suffix=".png") as file:
-            fig.savefig(file, format="png")
-            return _image_file_to_binary(file)
-
-if seaborn is not None:
-    def _seaborn_plot_to_binary(plot: seaborn.axisgrid.Grid) -> bytes:
-        with tempfile.NamedTemporaryFile(suffix=".png") as file:
-            plot.savefig(file)
-            return _image_file_to_binary(file)
-
-if PIL is not None:
-    def _pil_image_to_binary(img: PIL.Image.Image) -> bytes:
-        with tempfile.NamedTemporaryFile(suffix=".png") as file:
-            img.save(file, "png")
-            return _image_file_to_binary(file)
-
-
-def _image_file_to_binary(file: tempfile.NamedTemporaryFile) -> bytes:
-    file.seek(0)
-    return file.read()
diff --git a/spotfire/sbdf.pyx b/spotfire/sbdf.pyx
new file mode 100644
index 0000000..77ea1ab
--- /dev/null
+++ b/spotfire/sbdf.pyx
@@ -0,0 +1,1989 @@
+# cython: language_level=3
+
+# Copyright © 2022. Cloud Software Group, Inc.
+# This file is subject to the license terms contained
+# in the license file that is distributed with this file.
+
+"""Functions to import data tables into Python from SBDF files and export data from Python to SBDF files."""
+
+import collections.abc
+import datetime
+import decimal
+import io
+import warnings
+
+import cython
+
+import spotfire
+from spotfire import _utils
+
+import numpy as np
+import pandas as pd
+
+from libc cimport stdio, string, limits
+from cpython cimport datetime as datetime_c, mem
+cimport numpy as np_c
+from vendor cimport sbdf_c
+
+
+### Dynamically load optional modules
+try:
+    import geopandas as gpd
+    import shapely
+    import shapely.wkb
+    import shapely.geometry.base as shp_geom
+except ImportError:
+    gpd = None
+    shapely = None
+    shp_geom = None
+
+try:
+    import matplotlib
+    import matplotlib.figure
+    import matplotlib.pyplot
+except ImportError:
+    matplotlib = None
+
+try:
+    import seaborn
+except ImportError:
+    seaborn = None
+
+try:
+    import PIL
+    import PIL.Image
+except ImportError:
+    PIL = None
+
+
+### Various utility helper functions for doing things that are problematic in PYX files
+include "sbdf_helpers.pxi"
+
+
+### Initialize modules
+np_c.import_array()
+datetime_c.import_datetime()
+
+
+### Exceptions raised by this module
+class SBDFError(Exception):
+    """An exception that is raised to indicate a problem during import or export of SBDF files."""
+
+
+cdef public object PyExc_SBDFError = <object>SBDFError
+
+
+### Warnings raised by this module
+class SBDFWarning(Warning):
+    """A warning that is raised to indicate an issue during import or export of SBDF files."""
+
+
+### Utility functions and definitions for managing data types
+DEF _USECS_PER_MSEC = 1000
+DEF _MSECS_PER_SEC = 1000
+DEF _SECS_PER_DAY = 86400
+
+
+cdef object _timedelta_from_msec(long long msec):
+    """Create a new ``timedelta`` object based on a number of milliseconds.
+    
+    :param msec: number of milliseconds
+    :return: new timedelta object
+    """
+    cdef int days = <int>(msec // (_SECS_PER_DAY * _MSECS_PER_SEC))
+    cdef int sec = <int>((msec // _MSECS_PER_SEC) % _SECS_PER_DAY)
+    cdef int usec = <int>((msec % _MSECS_PER_SEC) * _USECS_PER_MSEC)
+    return datetime_c.timedelta_new(days, sec, usec)
+
+
+cdef object _DATETIME_EPOCH = datetime.datetime(1, 1, 1)
+cdef object _TIMEDELTA_ONE_MSEC = _timedelta_from_msec(1)
+
+
+DEF _DECIMAL_EXPONENT_BIAS = 6176
+
+
+cdef object _decimal_from_bytes(_SbdfDecimal* value):
+    """Convert a 16-byte SBDF decimal value to a Python ``Decimal`` object.
+    
+    :param value: SBDF decimal value
+    :return: new Decimal object
+    """
+    ## Coefficient
+    coefficient = 0
+    cdef int i
+    for i in range(11,-1,-1):
+        coefficient <<= 8
+        coefficient |= value.coeff[i]
+    ## Exponent
+    exponent = ((((value.exponent_high_and_sign << 8) | value.exponent_low) & 0x7FFE) >> 1) - _DECIMAL_EXPONENT_BIAS
+    ## Sign bit
+    if value.exponent_high_and_sign >> 7:
+        sign = 1
+    else:
+        sign = 0
+
+    # Break up the coefficient into digits
+    digits = []
+    while coefficient != 0:
+        digits.insert(0, coefficient % 10)
+        coefficient //= 10
+
+    return decimal.Decimal((sign, tuple(digits), exponent))
+
+
+cdef _SbdfDecimal _decimal_to_bytes(dec: decimal.Decimal) except *:
+    """Convert a Python ``Decimal`` object to a 16-byte SBDF decimal value.
+    
+    :param dec: Decimal object
+    :return: new SBDF decimal value
+    """
+    if not isinstance(dec, decimal.Decimal):
+        raise TypeError("cannot convert non-decimal")
+    cdef _SbdfDecimal out
+    cdef int i
+    dec_tuple = dec.as_tuple()
+    string.memset(&out, 0, sizeof(_SbdfDecimal))
+
+    ## Coefficient
+    coefficient = 0
+    for digit in dec_tuple.digits:
+        coefficient *= 10
+        coefficient += digit
+    for i in range(12):
+        out.coeff[i] = coefficient & 0xFF
+        coefficient >>= 8
+    if coefficient:
+        raise ValueError("too many digits in coefficient")
+    ## Exponent and sign bit
+    biased_exponent = dec_tuple.exponent + _DECIMAL_EXPONENT_BIAS
+    out.exponent_low = (biased_exponent & 0x7F) << 1
+    out.exponent_high_and_sign = (dec_tuple.sign << 7) | (biased_exponent >> 7)
+
+    return out
+
+
+def _valuetype_id_to_spotfire_typename(typeid: int):
+    """Convert an internal value type ID into the Spotfire type name that it represents.
+
+    :param typeid: the integer value type ID
+    :return: the Spotfire type name, or ``None`` if typeid does not represent a value type
+    """
+    if typeid == sbdf_c.SBDF_BOOLTYPEID:
+        return "Boolean"
+    elif typeid == sbdf_c.SBDF_INTTYPEID:
+        return "Integer"
+    elif typeid == sbdf_c.SBDF_LONGTYPEID:
+        return "LongInteger"
+    elif typeid == sbdf_c.SBDF_FLOATTYPEID:
+        return "SingleReal"
+    elif typeid == sbdf_c.SBDF_DOUBLETYPEID:
+        return "Real"
+    elif typeid == sbdf_c.SBDF_DATETIMETYPEID:
+        return "DateTime"
+    elif typeid == sbdf_c.SBDF_DATETYPEID:
+        return "Date"
+    elif typeid == sbdf_c.SBDF_TIMETYPEID:
+        return "Time"
+    elif typeid == sbdf_c.SBDF_TIMESPANTYPEID:
+        return "TimeSpan"
+    elif typeid == sbdf_c.SBDF_STRINGTYPEID:
+        return "String"
+    elif typeid == sbdf_c.SBDF_BINARYTYPEID:
+        return "Binary"
+    elif typeid == sbdf_c.SBDF_DECIMALTYPEID:
+        return "Currency"
+    else:
+        return None
+
+
+def spotfire_typename_to_valuetype_id(typename: str):
+    """Convert a Spotfire type name into the internal value type ID that it represents.
+
+    :param typename: the Spotfire type name
+    :return: the integer value type ID, or ``None`` if typename does not represent a value type
+    """
+    if typename == "Boolean":
+        return sbdf_c.SBDF_BOOLTYPEID
+    elif typename == "Integer":
+        return sbdf_c.SBDF_INTTYPEID
+    elif typename == "LongInteger":
+        return sbdf_c.SBDF_LONGTYPEID
+    elif typename == "SingleReal":
+        return sbdf_c.SBDF_FLOATTYPEID
+    elif typename == "Real":
+        return sbdf_c.SBDF_DOUBLETYPEID
+    elif typename == "DateTime":
+        return sbdf_c.SBDF_DATETIMETYPEID
+    elif typename == "Date":
+        return sbdf_c.SBDF_DATETYPEID
+    elif typename == "Time":
+        return sbdf_c.SBDF_TIMETYPEID
+    elif typename == "TimeSpan":
+        return sbdf_c.SBDF_TIMESPANTYPEID
+    elif typename == "String":
+        return sbdf_c.SBDF_STRINGTYPEID
+    elif typename == "Binary":
+        return sbdf_c.SBDF_BINARYTYPEID
+    elif typename == "Currency":
+        return sbdf_c.SBDF_DECIMALTYPEID
+    else:
+        return None
+
+
+### Import data from SBDF into Python.
+@cython.auto_pickle(False)
+cdef class _ImportContext:
+    """Object to store information for each column as it is imported."""
+    cdef int numpy_type_num
+    cdef sbdf_c.sbdf_valuetype value_type
+    cdef np_c.ndarray values_array
+    cdef np_c.ndarray invalid_array
+
+    def __init__(self, numpy_type_num: int):
+        """Initialize the import context, including the holding arrays.
+
+        :param numpy_type_num: NumPy type number for the value array; see
+                               https://numpy.org/doc/stable/reference/c-api/dtype.html#enumerated-types for more
+                               information
+        """
+        # Store the NumPy type number
+        self.numpy_type_num = numpy_type_num
+
+        # Initialize the SBDF value type
+        self.value_type = sbdf_c.sbdf_valuetype(0)
+
+        # Create a zero-element array for holding values
+        cdef np_c.npy_intp shape[1]
+        shape[0] = <np_c.npy_intp>0
+        self.values_array = np_c.PyArray_SimpleNew(1, shape, self.numpy_type_num)
+
+        # Create a zero-element array for holding invalids
+        self.invalid_array = np_c.PyArray_SimpleNew(1, shape, np_c.NPY_BOOL)
+
+    cdef (int, sbdf_c.sbdf_object*, sbdf_c.sbdf_object*) get_values_and_invalid(self, sbdf_c.sbdf_columnslice* col_slice):
+        """Extract the values and invalid arrays from the column slice.
+        
+        :param col_slice: the SBDF column slice to extract from
+        :return: tuple containing SBDF error code, SBDF value object, and SBDF invalids object
+        """
+        # Unpack the value array values
+        cdef sbdf_c.sbdf_object* values = NULL
+        cdef int error = sbdf_c.sbdf_va_get_values(col_slice.values, &values)
+        if error != sbdf_c.SBDF_OK:
+            return error, NULL, NULL
+        self.value_type = values.type
+
+        # Unpack the invalid value array
+        cdef sbdf_c.sbdf_valuearray* invalid_va = NULL
+        cdef sbdf_c.sbdf_object* invalid = NULL
+        error = sbdf_c.sbdf_cs_get_property(col_slice, sbdf_c.SBDF_ISINVALID_VALUEPROPERTY, &invalid_va)
+        if error == sbdf_c.SBDF_OK:
+            error = sbdf_c.sbdf_va_get_values(invalid_va, &invalid)
+            if error != sbdf_c.SBDF_OK:
+                self.cleanup_values_and_invalid(values, invalid)
+                return error, NULL, NULL
+        elif error != sbdf_c.SBDF_ERROR_PROPERTY_NOT_FOUND:
+            self.cleanup_values_and_invalid(values, invalid)
+            return error, NULL, NULL
+
+        return sbdf_c.SBDF_OK, values, invalid
+
+    cdef void cleanup_values_and_invalid(self, sbdf_c.sbdf_object* values, sbdf_c.sbdf_object* invalid):
+        """Properly clean up the values and invalid arrays from the SBDF API.
+        
+        :param values: SBDF value object to clean up
+        :param invalid: SBDF invalids object to clean up
+        """
+        if invalid != NULL:
+            sbdf_c.sbdf_obj_destroy(invalid)
+        sbdf_c.sbdf_obj_destroy(values)
+
+    cdef np_c.ndarray new_slice_from_data(self, int count, void* data):
+        """Create a NumPy slice ``ndarray`` from the given data.
+        
+        :param count: the number of values in the data
+        :param data: the data
+        :return: new values NumPy slice array
+        """
+        cdef np_c.npy_intp shape[1]
+        shape[0] = <np_c.npy_intp>count
+        return np_c.PyArray_SimpleNewFromData(1, shape, self.numpy_type_num, data)
+
+    cdef np_c.ndarray new_slice_from_empty(self, int count):
+        """Create a NumPy slice ``ndarray`` capable of holding the given amount of data, to be filled in later.
+        
+        :param count: the number of values to be able to hold
+        :return: new values NumPy slice array
+        """
+        cdef np_c.npy_intp shape[1]
+        shape[0] = <np_c.npy_intp>count
+        return np_c.PyArray_EMPTY(1, shape, self.numpy_type_num, 0)
+
+    cdef np_c.ndarray new_slice_from_invalid(self, int count, sbdf_c.sbdf_object* invalid):
+        """Create a boolean NumPy slice ``ndarray`` from the invalid array.
+        
+        :param count: the number of values in the data
+        :param invalid: the SBDF invalids object
+        :return: new invalid NumPy slice array
+        """
+        cdef np_c.npy_intp shape[1]
+        shape[0] = <np_c.npy_intp>count
+        if invalid != NULL:
+            return np_c.PyArray_SimpleNewFromData(1, shape, np_c.NPY_BOOL, <void*>invalid.data)
+        else:
+            return np_c.PyArray_ZEROS(1, shape, np_c.NPY_BOOL, 0)
+
+    cdef void append_values_slice(self, np_c.ndarray values_slice, np_c.ndarray invalid_slice):
+        """Append the NumPy slice arrays to the full table values
+        
+        :param values_slice: values NumPy slice array to append
+        :param invalid_slice: invalid NumPy slice array to append
+        """
+        self.values_array = np.append(self.values_array, values_slice)
+        self.invalid_array = np.append(self.invalid_array, invalid_slice)
+
+    cpdef np_c.ndarray get_values_array(self):
+        """Get the full table values ``ndarray``.
+        
+        :return: the full values NumPy array
+        """
+        return self.values_array
+
+    cpdef np_c.ndarray get_invalid_array(self):
+        """Get the full table invalid ``ndarray``.
+        
+        :return: the full invalid NumPy array
+        """
+        return self.invalid_array
+
+    def get_pandas_dtype_name(self) -> str:
+        """Get the correct Pandas dtype for this column.
+
+        :return: the Pandas dtype name for this column
+        """
+        if self.numpy_type_num == np_c.NPY_INT32:
+            return "Int32"
+        elif self.numpy_type_num == np_c.NPY_INT64:
+            return "Int64"
+        elif self.numpy_type_num == np_c.NPY_FLOAT32:
+            return "float32"
+        elif self.numpy_type_num == np_c.NPY_FLOAT64:
+            return "float64"
+        else:
+            return "object"
+
+    def get_spotfire_type_name(self) -> str:
+        """Get the correct Spotfire type name for this column.
+
+        :return: the Spotfire type name for this column
+        """
+        return _valuetype_id_to_spotfire_typename(self.value_type.id)
+
+
+## Individual functions for importing each value type.
+ctypedef int(*importer_fn)(_ImportContext, sbdf_c.sbdf_columnslice*)
+
+
+cdef int _import_vts_numpy(_ImportContext context, sbdf_c.sbdf_columnslice* col_slice):
+    """Import a column slice using NumPy to directly load from memory allocated by the SBDF API."""
+    cdef int error
+    (error, values, invalid) = context.get_values_and_invalid(col_slice)
+    if error == sbdf_c.SBDF_OK:
+        values_slice = context.new_slice_from_data(values.count, <void*>values.data)
+        invalid_slice = context.new_slice_from_invalid(values.count, invalid)
+        context.append_values_slice(values_slice, invalid_slice)
+        context.cleanup_values_and_invalid(values, invalid)
+    return error
+
+
+cdef int _import_vt_datetime(_ImportContext context, sbdf_c.sbdf_columnslice* col_slice):
+    """Import a column slice consisting of datetime values."""
+    cdef int error
+    (error, values, invalid) = context.get_values_and_invalid(col_slice)
+    cdef long long* data
+    cdef int i
+    if error == sbdf_c.SBDF_OK:
+        values_slice = context.new_slice_from_empty(values.count)
+        data = <long long*>values.data
+        for i in range(values.count):
+            values_slice[i] = _DATETIME_EPOCH + _timedelta_from_msec(data[i])
+        invalid_slice = context.new_slice_from_invalid(values.count, invalid)
+        context.append_values_slice(values_slice, invalid_slice)
+        context.cleanup_values_and_invalid(values, invalid)
+    return error
+
+
+cdef int _import_vt_date(_ImportContext context, sbdf_c.sbdf_columnslice* col_slice):
+    """Import a column slice consisting of date values."""
+    cdef int error
+    (error, values, invalid) = context.get_values_and_invalid(col_slice)
+    cdef long long* data
+    cdef int i
+    if error == sbdf_c.SBDF_OK:
+        values_slice = context.new_slice_from_empty(values.count)
+        data = <long long*>values.data
+        for i in range(values.count):
+            values_slice[i] = (_DATETIME_EPOCH + _timedelta_from_msec(data[i])).date()
+        invalid_slice = context.new_slice_from_invalid(values.count, invalid)
+        context.append_values_slice(values_slice, invalid_slice)
+        context.cleanup_values_and_invalid(values, invalid)
+    return error
+
+
+cdef int _import_vt_time(_ImportContext context, sbdf_c.sbdf_columnslice* col_slice):
+    """Import a column slice consisting of time values."""
+    cdef int error
+    (error, values, invalid) = context.get_values_and_invalid(col_slice)
+    cdef long long* data
+    cdef int i
+    if error == sbdf_c.SBDF_OK:
+        values_slice = context.new_slice_from_empty(values.count)
+        data = <long long*>values.data
+        for i in range(values.count):
+            values_slice[i] = (_DATETIME_EPOCH + _timedelta_from_msec(data[i])).timetz()
+        invalid_slice = context.new_slice_from_invalid(values.count, invalid)
+        context.append_values_slice(values_slice, invalid_slice)
+        context.cleanup_values_and_invalid(values, invalid)
+    return error
+
+
+cdef int _import_vt_timespan(_ImportContext context, sbdf_c.sbdf_columnslice* col_slice):
+    """Import a column slice consisting of timespan values."""
+    cdef int error
+    (error, values, invalid) = context.get_values_and_invalid(col_slice)
+    cdef long long* data
+    cdef int i
+    if error == sbdf_c.SBDF_OK:
+        values_slice = context.new_slice_from_empty(values.count)
+        data = <long long*>values.data
+        for i in range(values.count):
+            values_slice[i] = _timedelta_from_msec(data[i])
+        invalid_slice = context.new_slice_from_invalid(values.count, invalid)
+        context.append_values_slice(values_slice, invalid_slice)
+        context.cleanup_values_and_invalid(values, invalid)
+    return error
+
+
+cdef int _import_vt_string(_ImportContext context, sbdf_c.sbdf_columnslice* col_slice):
+    """Import a column slice consisting of string values."""
+    cdef int error
+    (error, values, invalid) = context.get_values_and_invalid(col_slice)
+    cdef char** data
+    cdef int i
+    if error == sbdf_c.SBDF_OK:
+        values_slice = context.new_slice_from_empty(values.count)
+        data = <char**>values.data
+        for i in range(values.count):
+            i_len = sbdf_c.sbdf_str_len(<const char*>data[i])
+            values_slice[i] = data[i][:i_len].decode("utf-8")
+        invalid_slice = context.new_slice_from_invalid(values.count, invalid)
+        context.append_values_slice(values_slice, invalid_slice)
+        context.cleanup_values_and_invalid(values, invalid)
+    return error
+
+
+cdef int _import_vt_bytes(_ImportContext context, sbdf_c.sbdf_columnslice* col_slice):
+    """Import a column slice consisting of bytes values."""
+    cdef int error
+    (error, values, invalid) = context.get_values_and_invalid(col_slice)
+    cdef void** data
+    cdef int i
+    if error == sbdf_c.SBDF_OK:
+        values_slice = context.new_slice_from_empty(values.count)
+        data = <void**>values.data
+        for i in range(values.count):
+            i_len = sbdf_c.sbdf_ba_get_len(<const unsigned char*>data[i])
+            values_slice[i] = (<char*>data[i])[:i_len]
+        invalid_slice = context.new_slice_from_invalid(values.count, invalid)
+        context.append_values_slice(values_slice, invalid_slice)
+        context.cleanup_values_and_invalid(values, invalid)
+    return error
+
+
+cdef int _import_vt_decimal(_ImportContext context, sbdf_c.sbdf_columnslice* col_slice):
+    """Import a column slice consisting of decimal values."""
+    cdef int error
+    (error, values, invalid) = context.get_values_and_invalid(col_slice)
+    cdef _SbdfDecimal* data
+    cdef int i
+    if error == sbdf_c.SBDF_OK:
+        values_slice = context.new_slice_from_empty(values.count)
+        data = <_SbdfDecimal*>values.data
+        for i in range(values.count):
+            values_slice[i] = _decimal_from_bytes(&data[i])
+        invalid_slice = context.new_slice_from_invalid(values.count, invalid)
+        context.append_values_slice(values_slice, invalid_slice)
+        context.cleanup_values_and_invalid(values, invalid)
+    return error
+
+
+cdef dict _import_metadata(sbdf_c.sbdf_metadata_head* md, int column_num):
+    """Process an SBDF API metadata structure into its Python equivalent.
+    
+    :param md: SBDF metadata structure
+    :param column_num: 0-based column number, or -1 for table metadata
+    """
+    metadata = {}
+    cdef sbdf_c.sbdf_metadata* md_iter = md.first
+    cdef sbdf_c.sbdf_object* obj = NULL
+    cdef int i
+    cdef char** data_string
+    cdef double* data_double
+    cdef long* data_long
+    cdef float* data_float
+    cdef int* data_int
+    cdef unsigned char* data_bool
+    cdef long long* data_datetime
+    cdef void** data_bytes
+    cdef _SbdfDecimal* data_decimal
+
+    if column_num == -1:
+        column_num_str = "Table"
+    else:
+        column_num_str = f"Column {column_num}"
+
+    while md_iter != NULL:
+        # Do not process the column name or type entries
+        if string.strcmp(md_iter.name, sbdf_c.SBDF_COLUMNMETADATA_NAME) == 0 or \
+           string.strcmp(md_iter.name, sbdf_c.SBDF_COLUMNMETADATA_DATATYPE) == 0:
+            md_iter = md_iter.next
+            continue
+
+        # Extract the metadata entry name
+        name = md_iter.name.decode("utf-8")
+
+        # Decode the value based on its type
+        obj = md_iter.value
+        md_val = []
+        if obj.type.id == sbdf_c.SBDF_STRINGTYPEID:
+            data_string = <char**>obj.data
+            for i in range(obj.count):
+                i_len = sbdf_c.sbdf_str_len(<const char*>data_string[i])
+                md_val.append(data_string[i][:i_len].decode("utf-8"))
+        elif obj.type.id == sbdf_c.SBDF_DOUBLETYPEID:
+            data_double = <double*>obj.data
+            for i in range(obj.count):
+                md_val.append(data_double[i])
+        elif obj.type.id == sbdf_c.SBDF_LONGTYPEID:
+            data_long = <long*>obj.data
+            for i in range(obj.count):
+                md_val.append(data_long[i])
+        elif obj.type.id == sbdf_c.SBDF_FLOATTYPEID:
+            data_float = <float*>obj.data
+            for i in range(obj.count):
+                md_val.append(data_float[i])
+        elif obj.type.id == sbdf_c.SBDF_INTTYPEID:
+            data_int = <int*>obj.data
+            for i in range(obj.count):
+                md_val.append(data_int[i])
+        elif obj.type.id == sbdf_c.SBDF_BOOLTYPEID:
+            data_bool = <unsigned char*>obj.data
+            for i in range(obj.count):
+                md_val.append(data_bool[i] != 0)
+        elif obj.type.id == sbdf_c.SBDF_DATETIMETYPEID:
+            data_datetime = <long long*>obj.data
+            for i in range(obj.count):
+                md_val.append(_DATETIME_EPOCH + _timedelta_from_msec(data_datetime[i]))
+        elif obj.type.id == sbdf_c.SBDF_DATETYPEID:
+            data_datetime = <long long*>obj.data
+            for i in range(obj.count):
+                md_val.append((_DATETIME_EPOCH + _timedelta_from_msec(data_datetime[i])).date())
+        elif obj.type.id == sbdf_c.SBDF_TIMETYPEID:
+            data_datetime = <long long*>obj.data
+            for i in range(obj.count):
+                md_val.append((_DATETIME_EPOCH + _timedelta_from_msec(data_datetime[i])).timetz())
+        elif obj.type.id == sbdf_c.SBDF_TIMESPANTYPEID:
+            data_datetime = <long long*>obj.data
+            for i in range(obj.count):
+                md_val.append(_timedelta_from_msec(data_datetime[i]))
+        elif obj.type.id == sbdf_c.SBDF_BINARYTYPEID:
+            data_bytes = <void**>obj.data
+            for i in range(obj.count):
+                i_len = sbdf_c.sbdf_ba_get_len(<const unsigned char*>data_bytes[i])
+                md_val.append((<char*>data_bytes[i])[:i_len])
+        elif obj.type.id == sbdf_c.SBDF_DECIMALTYPEID:
+            data_decimal = <_SbdfDecimal*>obj.data
+            for i in range(obj.count):
+                md_val.append(_decimal_from_bytes(&data_decimal[i]))
+
+        # Add the decoded value to the Python equivalent, and progress to the next entry
+        metadata[name] = md_val
+        md_iter = md_iter.next
+
+    return metadata
+
+
+def import_data(sbdf_file):
+    """Import data from an SBDF file and create a 'pandas' DataFrame.
+
+    :param sbdf_file: the filename of the SBDF file to import
+    :return: the DataFrame containing the imported data
+    :raises SBDFError: if a problem is encountered during import
+    """
+    cdef int error, i
+    cdef stdio.FILE* input_file = NULL
+    cdef int major_v, minor_v
+    cdef sbdf_c.sbdf_tablemetadata* table_meta = NULL
+    cdef importer_fn* importer_fns = NULL
+    cdef char* col_name
+    cdef sbdf_c.sbdf_valuetype col_type
+    cdef sbdf_c.sbdf_tableslice* table_slice
+    cdef sbdf_c.sbdf_columnslice* col_slice
+
+    try:
+        # Open the SBDF file
+        input_file = _pathlike_to_fileptr(sbdf_file, "rb")
+
+        # Read the file header
+        error = sbdf_c.sbdf_fh_read(input_file, &major_v, &minor_v)
+        if error != sbdf_c.SBDF_OK:
+            raise SBDFError(f"error reading '{sbdf_file}': {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+
+        # Examine the version information
+        if major_v != 1 or minor_v != 0:
+            raise SBDFError(f"error reading '{sbdf_file}': unsupported file version {major_v}.{minor_v}")
+
+        # Read the table metadata
+        error = sbdf_c.sbdf_tm_read(input_file, &table_meta)
+        if error != sbdf_c.SBDF_OK:
+            raise SBDFError(f"error reading '{sbdf_file}': {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+
+        # Parse metadata information
+        num_columns = table_meta.no_columns
+
+        # Add table metadata
+        table_metadata = _import_metadata(table_meta.table_metadata, -1)
+
+        # Determine the importer for each column
+        column_names = []
+        column_metadata = []
+        importer_contexts = []
+        importer_fns = <importer_fn*>mem.PyMem_RawMalloc(sizeof(importer_fn) * num_columns)
+
+        for i in range(num_columns):
+            sbdf_c.sbdf_cm_get_name(table_meta.column_metadata[i], &col_name)
+            sbdf_c.sbdf_cm_get_type(table_meta.column_metadata[i], &col_type)
+
+            column_names.append(col_name.decode('utf-8'))
+
+            if col_type.id == sbdf_c.SBDF_BOOLTYPEID:
+                importer_contexts.append(_ImportContext(np_c.NPY_BOOL))
+                importer_fns[i] = _import_vts_numpy
+            elif col_type.id == sbdf_c.SBDF_DOUBLETYPEID:
+                importer_contexts.append(_ImportContext(np_c.NPY_FLOAT64))
+                importer_fns[i] = _import_vts_numpy
+            elif col_type.id == sbdf_c.SBDF_LONGTYPEID:
+                importer_contexts.append(_ImportContext(np_c.NPY_INT64))
+                importer_fns[i] = _import_vts_numpy
+            elif col_type.id == sbdf_c.SBDF_FLOATTYPEID:
+                importer_contexts.append(_ImportContext(np_c.NPY_FLOAT32))
+                importer_fns[i] = _import_vts_numpy
+            elif col_type.id == sbdf_c.SBDF_INTTYPEID:
+                importer_contexts.append(_ImportContext(np_c.NPY_INT32))
+                importer_fns[i] = _import_vts_numpy
+            elif col_type.id == sbdf_c.SBDF_DATETIMETYPEID:
+                importer_contexts.append(_ImportContext(np_c.NPY_OBJECT))
+                importer_fns[i] = _import_vt_datetime
+            elif col_type.id == sbdf_c.SBDF_DATETYPEID:
+                importer_contexts.append(_ImportContext(np_c.NPY_OBJECT))
+                importer_fns[i] = _import_vt_date
+            elif col_type.id == sbdf_c.SBDF_TIMESPANTYPEID:
+                importer_contexts.append(_ImportContext(np_c.NPY_OBJECT))
+                importer_fns[i] = _import_vt_timespan
+            elif col_type.id == sbdf_c.SBDF_TIMETYPEID:
+                importer_contexts.append(_ImportContext(np_c.NPY_OBJECT))
+                importer_fns[i] = _import_vt_time
+            elif col_type.id == sbdf_c.SBDF_STRINGTYPEID:
+                importer_contexts.append(_ImportContext(np_c.NPY_OBJECT))
+                importer_fns[i] = _import_vt_string
+            elif col_type.id == sbdf_c.SBDF_BINARYTYPEID:
+                importer_contexts.append(_ImportContext(np_c.NPY_OBJECT))
+                importer_fns[i] = _import_vt_bytes
+            elif col_type.id == sbdf_c.SBDF_DECIMALTYPEID:
+                importer_contexts.append(_ImportContext(np_c.NPY_OBJECT))
+                importer_fns[i] = _import_vt_decimal
+            else:
+                raise SBDFError(f"column '{col_name}' has unsupported type id {col_type.id}")
+            sbdf_c.sbdf_str_destroy(col_name)
+            col_name = NULL
+
+            # Add column metadata
+            column_metadata.append(_import_metadata(table_meta.column_metadata[i], i))
+
+        # Read the table slices
+        while not error:
+            error = sbdf_c.sbdf_ts_read(input_file, table_meta, NULL, &table_slice)
+            if error != sbdf_c.SBDF_OK:
+                break
+
+            for i in range(table_slice.no_columns):
+                col_slice = table_slice.columns[i]
+                error = importer_fns[i](importer_contexts[i], col_slice)
+                if error != sbdf_c.SBDF_OK:
+                    break
+
+            # Destroy the table slice, including the value arrays
+            sbdf_c.sbdf_ts_destroy(table_slice)
+            table_slice = NULL
+
+        # Destroy the table metadata, which also destroys the column metadata
+        sbdf_c.sbdf_tm_destroy(table_meta)
+        table_meta = NULL
+
+        if error != sbdf_c.SBDF_OK and error != sbdf_c.SBDF_TABLEEND:
+            raise SBDFError(f"error reading '{sbdf_file}': {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+
+        # Build a new DataFrame with the results
+        imported_columns = []
+        for i in range(num_columns):
+            column_series = pd.Series(importer_contexts[i].get_values_array(),
+                                      dtype=importer_contexts[i].get_pandas_dtype_name(),
+                                      name=column_names[i])
+            column_series.loc[importer_contexts[i].get_invalid_array()] = None
+            imported_columns.append(column_series)
+        dataframe = pd.concat(imported_columns, axis=1)
+        for i in range(num_columns):
+            dataframe[column_names[i]].spotfire_column_metadata = column_metadata[i]
+            dataframe[column_names[i]].attrs['spotfire_type'] = importer_contexts[i].get_spotfire_type_name()
+        if gpd is not None and table_metadata.get('MapChart.IsGeocodingTable'):
+            # Turn the DataFrame into a GeoDataFrame if geopandas was detected and the table metadata
+            # indicates geocoding is present in the SBDF data
+            if 'Geometry' not in dataframe or not isinstance(dataframe['Geometry'][0], bytes):
+                raise SBDFError("cannot convert to GeoDataFrame")
+            # Convert to GeoDataFrame
+            geometry = []
+            for x in dataframe['Geometry']:
+                geometry.append(shapely.wkb.loads(x))
+            dataframe = dataframe.drop(columns='Geometry')
+            gdf = gpd.GeoDataFrame(dataframe, geometry=geometry)
+            spotfire.copy_metadata(dataframe, gdf)
+            # Determine the correct CRS to use
+            if 'MapChart.GeographicCrs' in table_metadata.keys() and table_metadata['MapChart.GeographicCrs'] != "":
+                proj = table_metadata['MapChart.GeographicCrs'][0]
+                gdf.crs = proj
+                try:
+                    # GeoPandas <= 0.6.3 compatibility
+                    if not gdf.crs.startswith("+init="):
+                        gdf.crs = f"+init={proj}"
+                except AttributeError:
+                    pass
+            dataframe = gdf
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")
+            dataframe.spotfire_table_metadata = table_metadata
+        return dataframe
+
+    finally:
+        # Close the input file
+        if input_file != NULL:
+            stdio.fclose(input_file)
+
+        # Free memory for the column importer bookkeeping
+        if importer_fns != NULL:
+            mem.PyMem_RawFree(importer_fns)
+
+
+### Export data to SBDF from Python.
+@cython.auto_pickle(False)
+cdef class _ExportContext:
+    """Object to store information for each column as it is exported."""
+    cdef int valuetype_id
+    cdef np_c.ndarray values_array
+    cdef np_c.ndarray invalid_array
+    cdef bint any_invalid
+
+    def __init__(self):
+        """Initialize the export context."""
+        self.valuetype_id = 0
+        self.values_array = None
+        self.invalid_array = None
+        self.any_invalid = False
+
+    cdef void set_arrays(self, np_c.ndarray values, invalid):
+        """Set the NumPy ``ndarray`` with the values to export and a list or NumPy ``ndarray`` of whether each value
+        is invalid.
+        
+        :param values: full values NumPy array
+        :param invalid: full invalids list or NumPy array
+        """
+        self.values_array = values
+        self.invalid_array = np.asarray(invalid, dtype="bool")
+        self.any_invalid = any(invalid)
+
+    def __len__(self):
+        if self.values_array is None:
+            return 0
+        else:
+            return np_c.PyArray_DIM(self.values_array, 0)
+
+    cdef void set_valuetype_id(self, valuetype_id: int):
+        """Set the value type to export this column as.
+        
+        :param valuetype_id: the integer value type ID 
+        """
+        self.valuetype_id = valuetype_id
+
+    cpdef int get_valuetype_id(self):
+        """Get the value type to export this column as.
+        
+        :return: the integer value type ID 
+        """
+        return self.valuetype_id
+
+    def get_numpy_dtype(self):
+        """Get the correct NumPy dtype for this column.
+
+        :return: the NumPy dtype name for this column
+        """
+        if self.valuetype_id == sbdf_c.SBDF_DOUBLETYPEID:
+            return "float64"
+        elif self.valuetype_id == sbdf_c.SBDF_LONGTYPEID:
+            return "int64"
+        elif self.valuetype_id == sbdf_c.SBDF_FLOATTYPEID:
+            return "float32"
+        elif self.valuetype_id == sbdf_c.SBDF_INTTYPEID:
+            return "int32"
+        elif self.valuetype_id == sbdf_c.SBDF_BOOLTYPEID:
+            return "bool"
+        else:
+            return "object"
+
+    def get_numpy_na_value(self):
+        """Get the correct value for representing a NA value in the values array.
+
+        :return: the value for representing an NA value
+        """
+        if self.valuetype_id == sbdf_c.SBDF_DOUBLETYPEID or self.valuetype_id == sbdf_c.SBDF_FLOATTYPEID:
+            return 0.
+        elif self.valuetype_id == sbdf_c.SBDF_LONGTYPEID or self.valuetype_id == sbdf_c.SBDF_INTTYPEID:
+            return 0
+        elif self.valuetype_id == sbdf_c.SBDF_BOOLTYPEID:
+            return False
+        else:
+            return None
+
+
+## Individual functions for extracting data and metadata for exporting each supported Python type.
+cdef _export_obj_dataframe(obj):
+    """Extract column information for a Pandas ``DataFrame``.
+    
+    :param obj: DataFrame object to export
+    :return: tuple containing dictionary of table metadata, list of column names, list of dictionaries of column
+              metadata, and list of export context objects
+    """
+    if len(set(obj.keys().to_list())) != len(obj.columns):
+        raise SBDFError("obj does not have unique column names")
+
+    # Table/column metadata and column information
+    try:
+        table_metadata = obj.spotfire_table_metadata
+    except AttributeError:
+        table_metadata = {}
+    export_column_names = obj.columns.tolist()
+    if gpd is not None and isinstance(obj, gpd.GeoDataFrame):
+        export_column_names = obj.columns.tolist()
+        # Remove columns that will be generated by the Geometry column
+        for col in ["XMin", "XMax", "YMin", "YMax", "XCenter", "YCenter"]:
+            if col in export_column_names:
+                export_column_names.remove(col)
+    column_names = []
+    column_metadata = []
+    exporter_contexts = []
+    for col in export_column_names:
+        if obj[col].dtype == 'geometry':
+            # Special case for the 'geometry' dtype from geopandas
+            _export_obj_geodataframe_geometry(obj[col], obj.crs, table_metadata, column_names, column_metadata,
+                                              exporter_contexts)
+        else:
+            # Normal columns
+            column_names.append(col)
+            context = _ExportContext()
+            if 'spotfire_type' in obj[col].attrs:
+                context.set_valuetype_id(_export_infer_valuetype_from_spotfire_typename(obj[col], f"column '{col}'"))
+            else:
+                context.set_valuetype_id(_export_infer_valuetype_from_pandas_dtype(obj[col], f"column '{col}'"))
+            na_value = context.get_numpy_na_value()
+            nas = {None: na_value,
+                   np.nan: na_value,
+                   pd.NA: na_value,
+                   pd.NaT: na_value,
+                   }
+            if obj[col].dtype == "object":
+                values = obj[col].replace(nas).to_numpy()
+            else:
+                values = obj[col].replace(nas).to_numpy(dtype=context.get_numpy_dtype())
+            invalids = pd.isnull(obj[col])
+            context.set_arrays(values, invalids)
+            exporter_contexts.append(context)
+            try:
+                column_metadata.append(obj[col].spotfire_column_metadata)
+            except AttributeError:
+                column_metadata.append({})
+
+    return table_metadata, column_names, column_metadata, exporter_contexts
+
+
+cdef void _export_obj_geodataframe_geometry(geometry, geometry_crs, table_metadata, column_names, column_metadata,
+                                            exporter_contexts):
+    """Extract column information and generate additional columns for the geometry of a GeoPandas ``GeoDataFrame``.
+    
+    :param geometry:
+    :param geometry_crs:
+    :param table_metadata: dict containing table metadata
+    :param column_names: list of column names
+    :param column_metadata: list of dictionaries containing column metadata
+    :param exporter_contexts: list of export context objects
+    """
+    cdef Py_ssize_t geometry_len = len(geometry)
+    cdef object geometry_bounds = geometry.bounds
+    cdef object geometry_centroid = geometry.centroid
+    cdef np_c.npy_intp shape[1]
+    shape[0] = <np_c.npy_intp>geometry_len
+    cdef np_c.ndarray invalids = np_c.PyArray_ZEROS(1, shape, np_c.NPY_BOOL, 0)
+    cdef np_c.ndarray values
+    cdef int i
+
+    ## Geometry
+    column_names.append("Geometry")
+    column_metadata.append({"MapChart.ColumnTypeId": ["Geometry"], "ContentType": ["application/x-wkb"]})
+    context = _ExportContext()
+    context.set_valuetype_id(sbdf_c.SBDF_BINARYTYPEID)
+    values = np_c.PyArray_ZEROS(1, shape, np_c.NPY_OBJECT, 0)
+    for i in range(geometry_len):
+        values[i] = shapely.wkb.dumps(geometry[i])
+    context.set_arrays(values, invalids)
+    exporter_contexts.append(context)
+
+    ## XMin
+    column_names.append("XMin")
+    column_metadata.append({"MapChart.ColumnTypeId": ["XMin"]})
+    context = _ExportContext()
+    context.set_valuetype_id(sbdf_c.SBDF_DOUBLETYPEID)
+    context.set_arrays(geometry_bounds["minx"].to_numpy(), invalids)
+    exporter_contexts.append(context)
+
+    ## XMax
+    column_names.append("XMax")
+    column_metadata.append({"MapChart.ColumnTypeId": ["XMax"]})
+    context = _ExportContext()
+    context.set_valuetype_id(sbdf_c.SBDF_DOUBLETYPEID)
+    context.set_arrays(geometry_bounds["maxx"].to_numpy(), invalids)
+    exporter_contexts.append(context)
+
+    ## YMin
+    column_names.append("YMin")
+    column_metadata.append({"MapChart.ColumnTypeId": ["YMin"]})
+    context = _ExportContext()
+    context.set_valuetype_id(sbdf_c.SBDF_DOUBLETYPEID)
+    context.set_arrays(geometry_bounds["miny"].to_numpy(), invalids)
+    exporter_contexts.append(context)
+
+    ## YMax
+    column_names.append("YMax")
+    column_metadata.append({"MapChart.ColumnTypeId": ["YMax"]})
+    context = _ExportContext()
+    context.set_valuetype_id(sbdf_c.SBDF_DOUBLETYPEID)
+    context.set_arrays(geometry_bounds["maxy"].to_numpy(), invalids)
+    exporter_contexts.append(context)
+
+    ## XCenter
+    column_names.append("XCenter")
+    column_metadata.append({"MapChart.ColumnTypeId": ["XCenter"]})
+    context = _ExportContext()
+    context.set_valuetype_id(sbdf_c.SBDF_DOUBLETYPEID)
+    values = np_c.PyArray_ZEROS(1, shape, np_c.NPY_FLOAT64, 0)
+    for i in range(geometry_len):
+        values[i] = geometry_centroid[i].x
+    context.set_arrays(values, invalids)
+    exporter_contexts.append(context)
+
+    ## YCenter
+    column_names.append("YCenter")
+    column_metadata.append({"MapChart.ColumnTypeId": ["YCenter"]})
+    context = _ExportContext()
+    context.set_valuetype_id(sbdf_c.SBDF_DOUBLETYPEID)
+    values = np_c.PyArray_ZEROS(1, shape, np_c.NPY_FLOAT64, 0)
+    for i in range(geometry_len):
+        values[i] = geometry_centroid[i].y
+    context.set_arrays(values, invalids)
+    exporter_contexts.append(context)
+
+    ## Table Metadata
+    table_metadata["MapChart.IsGeocodingTable"] = [True]
+    if not table_metadata.get("MapChart.IsGeocodingEnabled"):
+        table_metadata["MapChart.IsGeocodingEnabled"] = [True]
+    # Geometry types
+    if geometry.geom_type.nunique() != 1:
+        raise SBDFError("cannot convert mixed geometry types")
+    geom_type = geometry[0].geom_type
+    if geom_type == "Point":
+        table_metadata["MapChart.GeometryType"] = ["Point"]
+    elif geom_type == "LineString" or geom_type == "LinearRing":
+        table_metadata["MapChart.GeometryType"] = ["Line"]
+    elif geom_type == "MultiLineString":
+        table_metadata["MapChart.GeometryType"] = ["PolyLine"]
+    elif geom_type == "Polygon":
+        table_metadata["MapChart.GeometryType"] = ["Polygon"]
+    else:
+        raise SBDFError("cannot convert collections of Shapely objects")
+    # CRS
+    if geometry_crs is not None:
+        try:
+            table_metadata["MapChart.GeographicCrs"] = [geometry_crs.to_string()]
+        except AttributeError:
+            # GeoPandas <= 0.6.3
+            if geometry_crs.startsWith("+init="):
+                table_metadata["MapChart.GeographicCrs"] = [geometry_crs[6:]]
+            else:
+                table_metadata["MapChart.GeographicCrs"] = [geometry_crs]
+
+
+cdef _export_obj_series(obj, default_column_name):
+    """Extract column information for a Pandas ``Series``.
+    
+    :param obj: Series object to export
+    :param default_column_name: column name to use when obj does not have a name 
+    :return: tuple containing dict of table metadata, list of column names, list of dicts of column metadata, and
+              list of export context objects
+    """
+    if obj.name is None:
+        column_name = default_column_name
+        description = "series"
+    else:
+        column_name = obj.name
+        description = f"series '{obj.name}'"
+
+    # Column metadata and information
+    context = _ExportContext()
+    if 'spotfire_type' in obj.attrs:
+        context.set_valuetype_id(_export_infer_valuetype_from_spotfire_typename(obj, description))
+    else:
+        context.set_valuetype_id(_export_infer_valuetype_from_pandas_dtype(obj, description))
+    context.set_arrays(obj.to_numpy(context.get_numpy_dtype(), na_value=context.get_numpy_na_value()),
+                       _export_infer_invalids(obj))
+    try:
+        column_metadata = obj.spotfire_column_metadata
+    except AttributeError:
+        column_metadata = {}
+
+    return {}, [column_name], [column_metadata], [context]
+
+
+cdef _export_obj_numpy(np_c.ndarray obj, default_column_name):
+    """Extract column information for a NumPy ``ndarray``.
+    
+    :param obj: ndarray object to export
+    :param default_column_name: column name to use
+    :return: tuple containing dict of table metadata, list of column names, list of dicts of column metadata, and
+              list of export context objects
+    """
+    context = _ExportContext()
+    context.set_valuetype_id(_export_infer_valuetype_from_type(obj, "array"))
+    context.set_arrays(obj, _export_infer_invalids(obj))
+
+    return {}, [default_column_name], [{}], [context]
+
+
+cdef _export_obj_dict_of_lists(dict obj):
+    """Extract column information for a Python ``dict[str, list]``.
+    
+    :param obj: dict object mapping strings to lists to export
+    :return: tuple containing dict of table metadata, list of column names, list of dicts of column metadata, and
+              list of export context objects
+    """
+    cdef int i
+    cdef np_c.ndarray values
+
+    for l in obj.values():
+        if not isinstance(l, list):
+            raise SBDFError("obj is not a dict of lists")
+
+    # Column metadata and information
+    column_names = list(obj.keys())
+    column_metadata = []
+    exporter_contexts = []
+    for col in column_names:
+        context = _ExportContext()
+        context.set_valuetype_id(_export_infer_valuetype_from_type(obj[col], f"column '{col}'"))
+        shape = len(obj[col])
+        values = np.array(shape, dtype=context.get_numpy_dtype())
+        for i in range(shape):
+            if pd.isnull(obj[col][i]):
+                values[i] = context.get_numpy_na_value()
+            else:
+                values[i] = obj[col][i]
+        context.set_arrays(values, _export_infer_invalids(obj[col]))
+        exporter_contexts.append(context)
+        column_metadata.append({})
+
+    return {}, column_names, column_metadata, exporter_contexts
+
+
+cdef _export_obj_scalar(obj, default_column_name):
+    """Extract column information for a Python scalar value.
+    
+    :param obj: scalar value to export
+    :param default_column_name: column name to use
+    :return: tuple containing dict of table metadata, list of column names, list of dicts of column metadata, and
+              list of export context objects"""
+    cdef np_c.ndarray values
+
+    context = _ExportContext()
+    context.set_valuetype_id(_export_infer_valuetype_from_type([obj], "value"))
+    values = np.array([obj], dtype=context.get_numpy_dtype())
+    context.set_arrays(values, [False])
+
+    return {}, [default_column_name], [{}], [context]
+
+
+cdef _export_obj_iterable(obj, default_column_name):
+    """Extract column information for a Python iterable object.
+    
+    :param obj: iterable object to export
+    :param default_column_name: column name to use
+    :return: tuple containing dict of table metadata, list of column names, list of dicts of column metadata, and
+              list of export context objects
+    
+    .. seealso: https://docs.python.org/3/glossary.html#term-iterable
+    """
+    cdef np_c.ndarray values
+    cdef np_c.ndarray invalids
+
+    context = _ExportContext()
+    context.set_valuetype_id(_export_infer_valuetype_from_type(obj, "list"))
+    values = np.empty(0, dtype=context.get_numpy_dtype())
+    invalids = np.empty(0, dtype="bool")
+    for x in obj:
+        if pd.isnull(x):
+            values = np.append(values, context.get_numpy_na_value())
+            invalids = np.append(invalids, True)
+        else:
+            values = np.append(values, x)
+            invalids = np.append(invalids, False)
+    context.set_arrays(values, invalids)
+
+    return {}, [default_column_name], [{}], [context]
+
+
+cdef _export_obj_matplotlib_figure(obj, default_column_name):
+    """Extract column information for a Matplotlib ``figure``.
+    
+    :param obj: figure object to export
+    :param default_column_name: column name to use
+    :return: tuple containing dict of table metadata, list of column names, list of dicts of column metadata, and
+              list of export context objects
+    """
+    cdef np_c.ndarray values
+
+    context = _ExportContext()
+    context.set_valuetype(sbdf_c.sbdf_vt_binary())
+    with io.BytesIO() as buf:
+        obj.set_canvas(matplotlib.pyplot.gcf().canvas)
+        obj.savefig(buf, format="png")
+        values = np.array([buf.getvalue()], dtype='object')
+        context.set_arrays(values, [False])
+
+    return {}, [default_column_name], [{}], [context]
+
+
+cdef _export_obj_seaborn_grid(obj, default_column_name):
+    """Extract column information for a Seaborn ``Grid``.
+    
+    :param obj: grid object to export
+    :param default_column_name: column name to use
+    :return: tuple containing dict of table metadata, list of column names, list of dicts of column metadata, and
+              list of export context objects
+    """
+    cdef np_c.ndarray values
+
+    context = _ExportContext()
+    context.set_valuetype(sbdf_c.sbdf_vt_binary())
+    with io.BytesIO() as buf:
+        obj.savefig(buf)
+        values = np.array([buf.getvalue()], dtype='object')
+        context.set_arrays(values, [False])
+
+    return {}, [default_column_name], [{}], [context]
+
+
+cdef _export_obj_pil_image(obj, default_column_name):
+    """Extract column information for a PIL ``Image``.
+    
+    :param obj: image object to export
+    :param default_column_name: column name to use
+    :return: tuple containing dict of table metadata, list of column names, list of dicts of column metadata, and
+              list of export context objects
+    """
+    cdef np_c.ndarray values
+
+    context = _ExportContext()
+    context.set_valuetype(sbdf_c.sbdf_vt_binary())
+    with io.BytesIO() as buf:
+        obj.save(buf)
+        values = np.array([buf.getvalue()], dtype='object')
+        context.set_arrays(values, [False])
+
+    return {}, [default_column_name], [{}], [context]
+
+
+cdef np_c.ndarray _export_infer_invalids(values):
+    """Infer invalid array information from values.
+    
+    :param values: values to infer invalids from
+    :return: invalids NumPy array
+    """
+    cdef int i
+    cdef np_c.npy_intp shape[1]
+    shape[0] = <np_c.npy_intp>len(values)
+    cdef np_c.ndarray invalids = np_c.PyArray_ZEROS(1, shape, np_c.NPY_BOOL, 0)
+    for i in range(shape[0]):
+        if pd.isnull(values[i]):
+            invalids[i] = True
+    return invalids
+
+
+## Individual functions for exporting each Spotfire value type.
+ctypedef int(*exporter_fn)(_ExportContext, Py_ssize_t, Py_ssize_t, sbdf_c.sbdf_object**) except *
+
+
+cdef exporter_fn _export_get_exporter(int valuetype_id):
+    """Get the right exporter function for a value type.
+    
+    :param valuetype_id: the integer value type id
+    :return: the exporter function
+    """
+    if valuetype_id == sbdf_c.SBDF_BOOLTYPEID:
+        return _export_vt_bool
+    elif valuetype_id == sbdf_c.SBDF_INTTYPEID:
+        return _export_vt_int
+    elif valuetype_id == sbdf_c.SBDF_LONGTYPEID:
+        return _export_vt_long
+    elif valuetype_id == sbdf_c.SBDF_FLOATTYPEID:
+        return _export_vt_float
+    elif valuetype_id == sbdf_c.SBDF_DOUBLETYPEID:
+        return _export_vt_double
+    elif valuetype_id == sbdf_c.SBDF_DATETIMETYPEID:
+        return _export_vt_datetime
+    elif valuetype_id == sbdf_c.SBDF_DATETYPEID:
+        return _export_vt_date
+    elif valuetype_id == sbdf_c.SBDF_TIMETYPEID:
+        return _export_vt_time
+    elif valuetype_id == sbdf_c.SBDF_TIMESPANTYPEID:
+        return _export_vt_timespan
+    elif valuetype_id == sbdf_c.SBDF_STRINGTYPEID:
+        return _export_vt_string
+    elif valuetype_id == sbdf_c.SBDF_BINARYTYPEID:
+        return _export_vt_binary
+    elif valuetype_id == sbdf_c.SBDF_DECIMALTYPEID:
+        return _export_vt_decimal
+
+
+cdef int _export_vt_bool(_ExportContext context, Py_ssize_t start, Py_ssize_t count, sbdf_c.sbdf_object** obj) except *:
+    """Export a slice of data consisting of boolean values."""
+    cdef np_c.ndarray values
+    dtype = context.values_array.dtype
+    if dtype == "bool":
+        values = context.values_array
+    else:
+        values = context.values_array.astype("bool")
+    return sbdf_c.sbdf_obj_create_arr(sbdf_c.sbdf_vt_bool(), <int>count, _export_get_offset_ptr(values, start, count),
+                                      NULL, obj)
+
+
+cdef int _export_vt_int(_ExportContext context, Py_ssize_t start, Py_ssize_t count, sbdf_c.sbdf_object** obj) except *:
+    """Export a slice of data consisting of integer values."""
+    cdef np_c.ndarray values
+    dtype = context.values_array.dtype
+    if not np.can_cast(dtype, "int32") and dtype != "int64":
+        raise SBDFError(f"cannot convert '{context.values_array[0]}' to Spotfire Integer type; incompatible types")
+    if dtype == "int32":
+        values = context.values_array
+    else:
+        values = context.values_array.astype("int32")
+    return sbdf_c.sbdf_obj_create_arr(sbdf_c.sbdf_vt_int(), <int>count, _export_get_offset_ptr(values, start, count),
+                                      NULL, obj)
+
+
+cdef int _export_vt_long(_ExportContext context, Py_ssize_t start, Py_ssize_t count, sbdf_c.sbdf_object** obj) except *:
+    """Export a slice of data consisting of long integer values."""
+    cdef np_c.ndarray values
+    dtype = context.values_array.dtype
+    if not np.can_cast(dtype, "int64"):
+        raise SBDFError(f"cannot convert '{context.values_array[0]}' to Spotfire LongInteger type; incompatible types")
+    if dtype == "int64":
+        values = context.values_array
+    else:
+        values = context.values_array.astype("int64")
+    return sbdf_c.sbdf_obj_create_arr(sbdf_c.sbdf_vt_long(), <int>count, _export_get_offset_ptr(values, start, count),
+                                      NULL, obj)
+
+
+cdef int _export_vt_float(_ExportContext context, Py_ssize_t start, Py_ssize_t count, sbdf_c.sbdf_object** obj) except *:
+    """Export a slice of data consisting of float values."""
+    cdef np_c.ndarray values
+    dtype = context.values_array.dtype
+    if not np.can_cast(dtype, "float32") and dtype != "float64":
+        raise SBDFError(f"cannot convert '{context.values_array[0]}' to Spotfire SingleReal type; incompatible types")
+    if dtype == "float32":
+        values = context.values_array
+    else:
+        values = context.values_array.astype("float32")
+    return sbdf_c.sbdf_obj_create_arr(sbdf_c.sbdf_vt_float(), <int>count, _export_get_offset_ptr(values, start, count),
+                                      NULL, obj)
+
+
+cdef int _export_vt_double(_ExportContext context, Py_ssize_t start, Py_ssize_t count, sbdf_c.sbdf_object** obj) except *:
+    """Export a slice of data consisting of double values."""
+    cdef np_c.ndarray values
+    dtype = context.values_array.dtype
+    if not np.can_cast(dtype, "float64"):
+        raise SBDFError(f"cannot convert '{context.values_array[0]}' to Spotfire Real type; incompatible types")
+    if dtype == "float64":
+        values = context.values_array
+    else:
+        values = context.values_array.astype("float64")
+    return sbdf_c.sbdf_obj_create_arr(sbdf_c.sbdf_vt_double(), <int>count, _export_get_offset_ptr(values, start, count),
+                                      NULL, obj)
+
+
+cdef int _export_vt_datetime(_ExportContext context, Py_ssize_t start, Py_ssize_t count, sbdf_c.sbdf_object** obj) except *:
+    """Export a slice of data consisting of datetime values."""
+    cdef np_c.npy_intp shape[1]
+    shape[0] = <np_c.npy_intp>count
+    cdef np_c.ndarray new_values = np_c.PyArray_ZEROS(1, shape, np_c.NPY_INT64, 0)
+    cdef int i
+    for i in range(count):
+        if not context.invalid_array[start + i]:
+            val_i = context.values_array[start + i]
+            if isinstance(val_i, pd.Timestamp):
+                dt = val_i.to_pydatetime()
+            elif isinstance(val_i, np.datetime64):
+                dt = np.datetime64(val_i, "ms").astype(datetime.datetime)
+            elif isinstance(val_i, datetime.datetime):
+                dt = val_i
+            else:
+                raise SBDFError(f"cannot convert '{val_i}' to Spotfire DateTime type; incompatible types")
+            new_values[i] = int((dt - _DATETIME_EPOCH) / _TIMEDELTA_ONE_MSEC)
+    return sbdf_c.sbdf_obj_create_arr(sbdf_c.sbdf_vt_datetime(), <int>count, np_c.PyArray_DATA(new_values), NULL, obj)
+
+
+cdef int _export_vt_date(_ExportContext context, Py_ssize_t start, Py_ssize_t count, sbdf_c.sbdf_object** obj) except *:
+    """Export a slice of data consisting of date values."""
+    cdef np_c.npy_intp shape[1]
+    shape[0] = <np_c.npy_intp> count
+    cdef np_c.ndarray new_values = np_c.PyArray_ZEROS(1, shape, np_c.NPY_INT64, 0)
+    cdef int i
+    for i in range(count):
+        if not context.invalid_array[start + i]:
+            val_i = context.values_array[start + i]
+            if isinstance(val_i, pd.Timestamp):
+                val = val_i.date()
+            elif isinstance(val_i, datetime.date):
+                val = val_i
+            else:
+                raise SBDFError(f"cannot convert '{val_i}' to Spotfire Date type; incompatible types")
+            new_values[i] = int((val - _DATETIME_EPOCH.date()) / _TIMEDELTA_ONE_MSEC)
+    return sbdf_c.sbdf_obj_create_arr(sbdf_c.sbdf_vt_date(), <int>count, np_c.PyArray_DATA(new_values), NULL, obj)
+
+
+cdef int _export_vt_time(_ExportContext context, Py_ssize_t start, Py_ssize_t count, sbdf_c.sbdf_object** obj) except *:
+    """Export a slice of data consisting of time values."""
+    cdef np_c.npy_intp shape[1]
+    shape[0] = <np_c.npy_intp> count
+    cdef np_c.ndarray new_values = np_c.PyArray_ZEROS(1, shape, np_c.NPY_INT64, 0)
+    cdef int i
+    for i in range(count):
+        if not context.invalid_array[start + i]:
+            val_i = context.values_array[start + i]
+            if isinstance(val_i, datetime.time):
+                val = datetime.datetime.combine(datetime.datetime.min, val_i) - datetime.datetime.min
+            else:
+                raise SBDFError(f"cannot convert '{val_i}' to Spotfire Time type; incompatible types")
+            new_values[i] = val // _TIMEDELTA_ONE_MSEC
+    return sbdf_c.sbdf_obj_create_arr(sbdf_c.sbdf_vt_time(), <int>count, np_c.PyArray_DATA(new_values), NULL, obj)
+
+
+cdef int _export_vt_timespan(_ExportContext context, Py_ssize_t start, Py_ssize_t count, sbdf_c.sbdf_object** obj) except *:
+    """Export a slice of data consisting of timespan values."""
+    cdef np_c.npy_intp shape[1]
+    shape[0] = <np_c.npy_intp>count
+    cdef np_c.ndarray new_values = np_c.PyArray_ZEROS(1, shape, np_c.NPY_INT64, 0)
+    cdef int i
+    for i in range(count):
+        if not context.invalid_array[start + i]:
+            val_i = context.values_array[start + i]
+            if isinstance(val_i, pd.Timedelta):
+                td = val_i.to_pytimedelta()
+            elif isinstance(val_i, np.timedelta64):
+                td = np.timedelta64(val_i, "ms").astype(datetime.timedelta)
+            elif isinstance(val_i, datetime.timedelta):
+                td = val_i
+            else:
+                raise SBDFError(f"cannot convert '{val_i}' to Spotfire TimeSpan type; incompatible types")
+            new_values[i] = int(td / _TIMEDELTA_ONE_MSEC)
+    return sbdf_c.sbdf_obj_create_arr(sbdf_c.sbdf_vt_timespan(), <int>count, np_c.PyArray_DATA(new_values), NULL, obj)
+
+
+cdef int _export_vt_string(_ExportContext context, Py_ssize_t start, Py_ssize_t count, sbdf_c.sbdf_object** obj) except *:
+    """Export a slice of data consisting of string values."""
+    obj[0] = _export_extract_string_obj(context.values_array, context.invalid_array, start, count)
+    return sbdf_c.SBDF_OK
+
+
+cdef int _export_vt_binary(_ExportContext context, Py_ssize_t start, Py_ssize_t count, sbdf_c.sbdf_object** obj) except *:
+    """Export a slice of data consisting of binary values."""
+    obj[0] = _export_extract_binary_obj(context.values_array, context.invalid_array, start, count)
+    return sbdf_c.SBDF_OK
+
+
+cdef int _export_vt_decimal(_ExportContext context, Py_ssize_t start, Py_ssize_t count, sbdf_c.sbdf_object** obj) except *:
+    """Export a slice of data consisting of decimal values."""
+    cdef unsigned char* buf = <unsigned char*>mem.PyMem_RawMalloc(count * sizeof(_SbdfDecimal))
+    cdef int i
+    cdef _SbdfDecimal dec
+    try:
+        string.memset(buf, 0, count * sizeof(_SbdfDecimal))
+        for i in range(count):
+            if not context.invalid_array[start + i]:
+                val_i = context.values_array[start + i]
+                dec = _decimal_to_bytes(val_i)
+                string.memcpy(&buf[i * sizeof(_SbdfDecimal)], &dec, sizeof(_SbdfDecimal))
+        error = sbdf_c.sbdf_obj_create_arr(sbdf_c.sbdf_vt_decimal(), <int>count, buf, NULL, obj)
+        return error
+    except TypeError:
+        raise SBDFError(f"cannot convert '{val_i}' to Spotfire Currency type; incompatible types")
+    finally:
+        mem.PyMem_RawFree(buf)
+
+
+cdef bint _export_infer_int_promotion(values, values_description):
+    """Determine if values can be promoted from Integer type to LongInteger.
+    
+    :param values: the values to check for promotion
+    :param values_description: description of values (for error reporting)
+    :return: whether the values can be promoted
+    """
+    for i in values:
+        if isinstance(i, int) and not limits.INT_MIN < i < limits.INT_MAX:
+            warnings.warn(f"values in {values_description} do not fit in type 'Integer'; "
+                          "promoting type to 'LongInteger'", SBDFWarning)
+            return True
+    return False
+
+
+cdef int _export_infer_valuetype_from_type(values, values_description) except *:
+    """Determine a value type for a data set based on the Python type of the objects in the values list.
+    
+    :param values: the values to infer the value type of
+    :param values_description: description of values (for error reporting)
+    :return: the integer value type id representing the type of values
+    :raise SBDFError: if the types of values are mixed, all missing, or unknown
+    """
+    # Remove any None (or other none-ish things) from values
+    if isinstance(values, pd.Series):
+        vals = values.dropna().tolist()
+    else:
+        vals = []
+        for x in values:
+            if not pd.isnull(x):
+                vals.append(x)
+
+    # Check if any values remain
+    if not vals:
+        raise SBDFError(f"cannot determine type for {values_description}; all values are missing")
+
+    # Check to make sure only one type remains
+    vals_type = type(vals[0])
+    for val in vals:
+        if not isinstance(val, vals_type):
+            raise SBDFError(f"types in {values_description} do not match")
+
+    # Determine the right type id
+    if vals_type is bool:
+        return sbdf_c.SBDF_BOOLTYPEID
+    elif vals_type is np.int32:
+        return sbdf_c.SBDF_INTTYPEID
+    elif vals_type is int:
+        return sbdf_c.SBDF_LONGTYPEID
+    elif vals_type is np.int64:
+        return sbdf_c.SBDF_LONGTYPEID
+    elif vals_type is np.float32:
+        return sbdf_c.SBDF_FLOATTYPEID
+    elif vals_type is float:
+        return sbdf_c.SBDF_DOUBLETYPEID
+    elif vals_type is np.float64:
+        return sbdf_c.SBDF_DOUBLETYPEID
+    elif vals_type is datetime.datetime:
+        return sbdf_c.SBDF_DATETIMETYPEID
+    elif vals_type is pd.Timestamp:
+        return sbdf_c.SBDF_DATETIMETYPEID
+    elif vals_type is datetime.date:
+        return sbdf_c.SBDF_DATETYPEID
+    elif vals_type is datetime.time:
+        return sbdf_c.SBDF_TIMETYPEID
+    elif vals_type is datetime.timedelta:
+        return sbdf_c.SBDF_TIMESPANTYPEID
+    elif vals_type is pd.Timedelta:
+        return sbdf_c.SBDF_TIMESPANTYPEID
+    elif vals_type is str:
+        return sbdf_c.SBDF_STRINGTYPEID
+    elif vals_type is bytes:
+        return sbdf_c.SBDF_BINARYTYPEID
+    elif vals_type is decimal.Decimal:
+        return sbdf_c.SBDF_DECIMALTYPEID
+    else:
+        raise SBDFError(f"unknown type '{_utils.type_name(vals_type)}' in {values_description}")
+
+
+cdef int _export_infer_valuetype_from_pandas_dtype(series, series_description) except *:
+    """Determine a value type for a data set based on the Pandas dtype for the series.
+    
+    :param series: the values to infer the value type of
+    :param series_description: description of series (for error reporting)
+    :return: the integer value type id representing the type of series
+    :raise SBDFError: if the types of values are mixed, all missing, or unknown
+    """
+    dtype = series.dtype.name
+    if dtype == "object":
+        return _export_infer_valuetype_from_type(series, series_description)
+    elif dtype == "category":
+        return _export_infer_valuetype_from_pandas_dtype(series.astype(series.cat.categories.dtype), series_description)
+    elif dtype == "bool":
+        return sbdf_c.SBDF_BOOLTYPEID
+    elif dtype == "boolean":
+        return sbdf_c.SBDF_BOOLTYPEID
+    elif dtype == "int32":
+        return sbdf_c.SBDF_INTTYPEID
+    elif dtype == "Int32":
+        return sbdf_c.SBDF_INTTYPEID
+    elif dtype == "int64":
+        return sbdf_c.SBDF_LONGTYPEID
+    elif dtype == "Int64":
+        return sbdf_c.SBDF_LONGTYPEID
+    elif dtype == "float32":
+        return sbdf_c.SBDF_FLOATTYPEID
+    elif dtype == "Float32":
+        return sbdf_c.SBDF_FLOATTYPEID
+    elif dtype == "float64":
+        return sbdf_c.SBDF_DOUBLETYPEID
+    elif dtype == "Float64":
+        return sbdf_c.SBDF_DOUBLETYPEID
+    elif dtype == "datetime64[ns]":
+        return sbdf_c.SBDF_DATETIMETYPEID
+    elif dtype == "timedelta64[ns]":
+        return sbdf_c.SBDF_TIMESPANTYPEID
+    elif dtype == "string":
+        return sbdf_c.SBDF_STRINGTYPEID
+    else:
+        raise SBDFError(f"unknown dtype '{dtype}' in {series_description}")
+
+
+cdef object _VT_CONVERSIONS_ALL = [sbdf_c.SBDF_BOOLTYPEID, sbdf_c.SBDF_STRINGTYPEID]
+cdef object _VT_CONVERSIONS_NUMERIC = [sbdf_c.SBDF_BOOLTYPEID, sbdf_c.SBDF_INTTYPEID, sbdf_c.SBDF_LONGTYPEID,
+                                       sbdf_c.SBDF_FLOATTYPEID, sbdf_c.SBDF_DOUBLETYPEID]
+
+
+cdef int _export_infer_valuetype_from_spotfire_typename(series, series_description) except *:
+    """Determine a value type for a data set based on the name of the Spotfire type.
+    
+    :param series: the values to infer the value type of
+    :param series_description: description of series (for error reporting)
+    :return: the integer value type id representing the type of series
+    :raise SBDFError: if the types of series are inconvertible, mixed, all missing, or unknown
+    """
+    # Determine if a type has been specified.
+    typename = series.attrs['spotfire_type']
+    specified_vt = spotfire_typename_to_valuetype_id(typename)
+
+    # Verify the specified type is allowed to be converted from.
+    if specified_vt == sbdf_c.SBDF_INTTYPEID and _export_infer_int_promotion(series, series_description):
+        # special case for int promotion
+        return sbdf_c.SBDF_LONGTYPEID
+    elif specified_vt is not None:
+        # Type was specified; return immediately if allowable.
+        # Conversions to Boolean or String allowed from all types.
+        if specified_vt in _VT_CONVERSIONS_ALL:
+            return specified_vt
+        # Other conversions are dependent on the inferred type.
+        try:
+            inferred_vt = _export_infer_valuetype_from_pandas_dtype(series, series_description)
+        except SBDFError:
+            # If no inferred type, accept the specified type.
+            return specified_vt
+        # Conversions between Boolean, Integer, LongInteger, Real, and SingleReal are all allowed.
+        if specified_vt in _VT_CONVERSIONS_NUMERIC and inferred_vt in _VT_CONVERSIONS_NUMERIC:
+            return specified_vt
+        # Otherwise, the specified type must match the inferred type
+        if specified_vt == inferred_vt:
+            return specified_vt
+        # Raise an error if not allowable
+        raise SBDFError(f"cannot convert Spotfire {_valuetype_id_to_spotfire_typename(inferred_vt)} type to Spotfire"
+                        f" {typename} type; incompatible types")
+    else:
+        # No type was specified; return the inferred type.
+        return _export_infer_valuetype_from_pandas_dtype(series, series_description)
+
+
+cdef int _export_get_value_encoding(int valuetype_id, bint encoding_rle):
+    """Determine the correct SBDF encoding to use for a column.
+    
+    :param valuetype_id: the integer value type id of the column
+    :param encoding_rle: whether RLE encoding was requested by the caller of export_data
+    :return: the integer SBDF encoding constant for the column
+    """
+    if valuetype_id == sbdf_c.SBDF_BOOLTYPEID:
+        return sbdf_c.SBDF_BITARRAYENCODINGTYPEID
+    elif encoding_rle and valuetype_id != sbdf_c.SBDF_BINARYTYPEID:
+        return sbdf_c.SBDF_RUNLENGTHENCODINGTYPEID
+    else:
+        return sbdf_c.SBDF_PLAINARRAYENCODINGTYPEID
+
+
+cdef (int, sbdf_c.sbdf_valuearray*) _export_process_invalid_array(_ExportContext context,
+                                                                  Py_ssize_t start, Py_ssize_t count,
+                                                                  sbdf_c.sbdf_columnslice* col_slice):
+    """Process the invalid array into SBDF API structures.
+    
+    :param context: the export context containing information about the column
+    :param start: the index of the first row in the column slice to create
+    :param count: the number of rows in the column slice to create
+    :param col_slice: the SBDF column slice to add the invalid array to
+    :return: Cython C-tuple containing SBDF error code and created SBDF value array  
+    """
+    cdef sbdf_c.sbdf_object* invalids = NULL
+    cdef sbdf_c.sbdf_valuearray* invalid_array = NULL
+    if context.any_invalid:
+        error = sbdf_c.sbdf_obj_create_arr(sbdf_c.sbdf_vt_bool(), <int>count,
+                                           _export_get_offset_ptr(context.invalid_array, start, count),
+                                           NULL, &invalids)
+        if error != sbdf_c.SBDF_OK:
+            return error, NULL
+        error = sbdf_c.sbdf_va_create_dflt(invalids, &invalid_array)
+        if error != sbdf_c.SBDF_OK:
+            return error, NULL
+        sbdf_c.sbdf_obj_destroy(invalids)
+        error = sbdf_c.sbdf_cs_add_property(col_slice, sbdf_c.SBDF_ISINVALID_VALUEPROPERTY, invalid_array)
+        if error != sbdf_c.SBDF_OK:
+            return error, NULL
+        return error, invalid_array
+    else:
+        return sbdf_c.SBDF_OK, NULL
+
+
+cdef inline void* _export_get_offset_ptr(np_c.ndarray array, Py_ssize_t start, Py_ssize_t count):
+    """Slice a NumPy ``ndarray`` using Cython memoryviews.
+    
+    :param array: the NumPy array to slice
+    :param start: the index of the first element of the slice
+    :param count: the number of elements to include in the slice
+    :return: a pointer to the memory (owned by the NumPy array) of the slice
+    """
+    cdef np_c.ndarray sliced = array[start : start + count]
+    return np_c.PyArray_DATA(sliced)
+
+
+cdef sbdf_c.sbdf_metadata_head* _export_metadata(dict md, int column_num) except *:
+    """Process a Python metadata representation into its SBDF API equivalent
+    
+    :param md: dictionary containing table or column metadata
+    :param column_num: 0-based column number, or -1 for table metadata (for error reporting)
+    :return: SBDF metadata structure
+    :raise SBDFError: if errors occur in the SBDF C library
+    """
+    cdef sbdf_c.sbdf_metadata_head* md_head = NULL
+    cdef sbdf_c.sbdf_object* obj = NULL
+    cdef int val_len
+    cdef sbdf_c.sbdf_valuetype val_type
+    cdef int* data_lengths = NULL
+    cdef char** data_string
+    cdef double* data_double
+    cdef long* data_long
+    cdef float* data_float
+    cdef int* data_int
+    cdef unsigned char* data_bool
+    cdef long long* data_datetime
+    cdef unsigned char** data_bytes
+    cdef _SbdfDecimal* data_decimal
+
+    error = sbdf_c.sbdf_md_create(&md_head)
+    if error != sbdf_c.SBDF_OK:
+        raise SBDFError(f"cannot create metadata: {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+
+    if column_num == -1:
+        metadata_description = "Table"
+    else:
+        metadata_description = f"Column {column_num}"
+
+    for (name_str, val) in md.items():
+        name = name_str.encode("utf-8")
+        if not isinstance(val, list):
+            val = [val]
+        val_len = <int>len(val)
+        if val_len > 1:
+            raise SBDFError(f"{metadata_description} metadata '{name_str}' is not length 1")
+        val_type.id = _export_infer_valuetype_from_type(val, f"{metadata_description} metadata '{name_str}'")
+
+        if val_type.id == sbdf_c.SBDF_STRINGTYPEID:
+            obj = _export_extract_string_obj(val, [False] * val_len, 0, val_len)
+            error = sbdf_c.SBDF_OK
+        elif val_type.id == sbdf_c.SBDF_DOUBLETYPEID:
+            data_double = <double*>mem.PyMem_RawMalloc(val_len * sizeof(double))
+            for i in range(val_len):
+                data_double[i] = val[i]
+            error = sbdf_c.sbdf_obj_create_arr(val_type, val_len, data_double, NULL, &obj)
+            mem.PyMem_RawFree(<void*>data_double)
+        elif val_type.id == sbdf_c.SBDF_LONGTYPEID:
+            data_long = <long*>mem.PyMem_RawMalloc(val_len * sizeof(long))
+            for i in range(val_len):
+                data_long[i] = val[i]
+            error = sbdf_c.sbdf_obj_create_arr(val_type, val_len, data_long, NULL, &obj)
+            mem.PyMem_RawFree(<void*>data_long)
+        elif val_type.id == sbdf_c.SBDF_FLOATTYPEID:
+            data_float = <float*>mem.PyMem_RawMalloc(val_len * sizeof(float))
+            for i in range(val_len):
+                data_float[i] = val[i]
+            error = sbdf_c.sbdf_obj_create_arr(val_type, val_len, data_float, NULL, &obj)
+            mem.PyMem_RawFree(<void*>data_float)
+        elif val_type.id == sbdf_c.SBDF_INTTYPEID:
+            data_int = <int*>mem.PyMem_RawMalloc(val_len * sizeof(int))
+            for i in range(val_len):
+                data_int[i] = val[i]
+            error = sbdf_c.sbdf_obj_create_arr(val_type, val_len, data_int, NULL, &obj)
+            mem.PyMem_RawFree(<void*>data_int)
+        elif val_type.id == sbdf_c.SBDF_BOOLTYPEID:
+            data_bool = <unsigned char*>mem.PyMem_RawMalloc(val_len * sizeof(unsigned char))
+            for i in range(val_len):
+                data_bool[i] = 1 if val[i] else 0
+            error = sbdf_c.sbdf_obj_create_arr(val_type, val_len, data_bool, NULL, &obj)
+            mem.PyMem_RawFree(<void*>data_bool)
+        elif val_type.id == sbdf_c.SBDF_DATETIMETYPEID:
+            data_datetime = <long long*>mem.PyMem_RawMalloc(val_len * sizeof(long long))
+            for i in range(val_len):
+                dt = val[i].to_pydatetime() if isinstance(val[i], pd.Timestamp) else val[i]
+                data_datetime[i] = int((dt - _DATETIME_EPOCH)/_TIMEDELTA_ONE_MSEC)
+            error = sbdf_c.sbdf_obj_create_arr(val_type, val_len, data_datetime, NULL, &obj)
+            mem.PyMem_RawFree(<void*>data_datetime)
+        elif val_type.id == sbdf_c.SBDF_DATETYPEID:
+            data_datetime = <long long *>mem.PyMem_RawMalloc(val_len * sizeof(long long))
+            for i in range(val_len):
+                data_datetime[i] = int((val[i] - _DATETIME_EPOCH.date())/_TIMEDELTA_ONE_MSEC)
+            error = sbdf_c.sbdf_obj_create_arr(val_type, val_len, data_datetime, NULL, &obj)
+            mem.PyMem_RawFree(<void*>data_datetime)
+        elif val_type.id == sbdf_c.SBDF_TIMETYPEID:
+            data_datetime = <long long *>mem.PyMem_RawMalloc(val_len * sizeof(long long))
+            for i in range(val_len):
+                data_datetime[i] = (datetime.datetime.combine(datetime.datetime.min, val[i]) - datetime.datetime.min) \
+                                   // _TIMEDELTA_ONE_MSEC
+            error = sbdf_c.sbdf_obj_create_arr(val_type, val_len, data_datetime, NULL, &obj)
+            mem.PyMem_RawFree(<void*>data_datetime)
+        elif val_type.id == sbdf_c.SBDF_TIMESPANTYPEID:
+            data_datetime = <long long*>mem.PyMem_RawMalloc(val_len * sizeof(long long))
+            for i in range(val_len):
+                td = val[i].to_pytimedelta() if isinstance(val[i], pd.Timedelta) else val[i]
+                data_datetime[i] = int(td / _TIMEDELTA_ONE_MSEC)
+            error = sbdf_c.sbdf_obj_create_arr(val_type, val_len, data_datetime, NULL, &obj)
+            mem.PyMem_RawFree(<void*>data_datetime)
+        elif val_type.id == sbdf_c.SBDF_BINARYTYPEID:
+            obj = _export_extract_binary_obj(val, [False] * val_len, 0, val_len)
+            error = sbdf_c.SBDF_OK
+        elif val_type.id == sbdf_c.SBDF_DECIMALTYPEID:
+            data_decimal = <_SbdfDecimal*>mem.PyMem_RawMalloc(val_len * sizeof(_SbdfDecimal))
+            for i in range(val_len):
+                data_decimal[i] = _decimal_to_bytes(val[i])
+            error = sbdf_c.sbdf_obj_create_arr(val_type, val_len, data_decimal, data_lengths, &obj)
+            mem.PyMem_RawFree(<void*>data_decimal)
+        if error != sbdf_c.SBDF_OK:
+            raise SBDFError(f"cannot create metadata object: {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+
+        # Add the metadata item
+        error = sbdf_c.sbdf_md_add(name, obj, NULL, md_head)
+        if error != sbdf_c.SBDF_OK:
+            raise SBDFError(f"cannot add metadata: {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+        sbdf_c.sbdf_obj_destroy(obj)
+        obj = NULL
+
+    return md_head
+
+
+def export_data(obj, sbdf_file, default_column_name="x", Py_ssize_t rows_per_slice=0, encoding_rle=True):
+    """Export data to an SBDF file.
+
+    :param obj: the data object to export
+    :param sbdf_file: the filename to export the data to
+    :param default_column_name: the column name to use when exporting data objects without intrinsic names (such as
+                                lists or scalar values)
+    :param rows_per_slice: the number of data rows to emit per table slice.  If 0, automatically determine an
+                           appropriate value
+    :param encoding_rle: should the table slices be encoded using RLE?
+    :raises SBDFError: if a problem is encountered during export
+    """
+    cdef int i
+    cdef stdio.FILE* output_file = NULL
+    cdef sbdf_c.sbdf_metadata_head* table_md = NULL
+    cdef sbdf_c.sbdf_tablemetadata* table_meta = NULL
+    cdef Py_ssize_t num_columns
+    cdef sbdf_c.sbdf_metadata_head* col_md = NULL
+    cdef sbdf_c.sbdf_valuetype col_vt
+    cdef Py_ssize_t row_count = 0
+    cdef Py_ssize_t row_offset = 0
+    cdef Py_ssize_t slice_row_count
+    cdef _AllocatedList saved_value_arrays
+    cdef _AllocatedList saved_col_slices
+    cdef sbdf_c.sbdf_tableslice* table_slice = NULL
+    cdef exporter_fn exporter
+    cdef sbdf_c.sbdf_object* values = NULL
+    cdef int value_encoding = 0
+    cdef sbdf_c.sbdf_valuearray* value_array = NULL
+    cdef sbdf_c.sbdf_columnslice* col_slice = NULL
+    cdef sbdf_c.sbdf_valuearray* invalid_array = NULL
+
+    try:
+        # Extract data and metadata from obj
+        ## Pandas DataFrames (tabular)
+        if isinstance(obj, pd.DataFrame):
+            exported = _export_obj_dataframe(obj)
+        ## Pandas Series (columnar)
+        elif isinstance(obj, pd.Series):
+            exported = _export_obj_series(obj, default_column_name)
+        ## NumPy Array (columnar)
+        elif isinstance(obj, np_c.ndarray):
+            exported = _export_obj_numpy(obj, default_column_name)
+        ## Dicts of Lists (tabular)
+        elif isinstance(obj, dict):
+            exported = _export_obj_dict_of_lists(obj)
+        ## Strings and Bytes (scalar)
+        elif isinstance(obj, (str, bytes, bytearray)):
+            exported = _export_obj_scalar(obj, default_column_name)
+        ## Iterable (columnar)
+        elif isinstance(obj, collections.abc.Iterable):
+            exported = _export_obj_iterable(obj, default_column_name)
+        ## Matplotlib Figures (scalar)
+        elif matplotlib is not None and isinstance(obj, matplotlib.figure.Figure):
+            exported = _export_obj_matplotlib_figure(obj, default_column_name)
+        ## Seaborn Grids (scalar)
+        elif seaborn is not None and isinstance(obj, seaborn.axisgrid.Grid):
+            exported = _export_obj_seaborn_grid(obj, default_column_name)
+        ## PIL Images (scalar)
+        elif PIL is not None and isinstance(obj, PIL.Image.Image):
+            exported = _export_obj_pil_image(obj, default_column_name)
+        ## Try if all else fails (scalar)
+        else:
+            exported = _export_obj_scalar(obj, default_column_name)
+        table_metadata, column_names, column_metadata, exporter_contexts = exported
+
+        # Open the SBDF file
+        output_file = _pathlike_to_fileptr(sbdf_file, "wb")
+
+        # Create the table metadata structures
+        table_md = _export_metadata(table_metadata, -1)
+        error = sbdf_c.sbdf_tm_create(table_md, &table_meta)
+        if error != sbdf_c.SBDF_OK:
+            raise SBDFError(f"cannot create table metadata: {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+        sbdf_c.sbdf_md_destroy(table_md)
+
+        # Create the column metadata structures
+        num_columns = len(column_names)
+        for i in range(num_columns):
+            if i == 0:
+                row_count = len(exporter_contexts[i])
+            else:
+                if row_count != len(exporter_contexts[i]):
+                    raise SBDFError(f"column '{column_names[i]}' has inconsistent column length")
+            col = column_names[i].encode('utf-8')
+            col_md = _export_metadata(column_metadata[i], i)
+            col_vt.id = exporter_contexts[i].get_valuetype_id()
+            error = sbdf_c.sbdf_cm_set_values(col, col_vt, col_md)
+            if error != sbdf_c.SBDF_OK:
+                raise SBDFError(f"cannot create column metadata: {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+            error = sbdf_c.sbdf_tm_add(col_md, table_meta)
+            if error != sbdf_c.SBDF_OK:
+                raise SBDFError(f"cannot add column metadata: {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+            sbdf_c.sbdf_md_destroy(col_md)
+
+        # Write the file header
+        error = sbdf_c.sbdf_fh_write_cur(output_file)
+        if error != sbdf_c.SBDF_OK:
+            raise SBDFError(f"error writing '{sbdf_file}': {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+
+        # Write the table metadata
+        error = sbdf_c.sbdf_tm_write(output_file, table_meta)
+        if error != sbdf_c.SBDF_OK:
+            raise SBDFError(f"error writing '{sbdf_file}': {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+        sbdf_c.sbdf_tm_destroy(table_meta)
+
+        # Determine the number of rows per slice
+        if rows_per_slice <= 0:
+            rows_per_slice = max(10, 100000 // max(1, num_columns))
+
+        # Slice the data
+        _allocated_list_new(&saved_col_slices, num_columns)
+        _allocated_list_new(&saved_value_arrays, num_columns * 2)
+        while row_offset < row_count:
+            rows_per_slice = min(rows_per_slice, row_count - row_offset)
+
+            # Create the table slice
+            error = sbdf_c.sbdf_ts_create(table_meta, &table_slice)
+            if error != sbdf_c.SBDF_OK:
+                raise SBDFError(f"error creating table slice: {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+
+            # Add each column to the slice
+            for i in range(num_columns):
+                values = NULL
+                context = exporter_contexts[i]
+                exporter = _export_get_exporter(context.get_valuetype_id())
+                error = exporter(context, row_offset, rows_per_slice, &values)
+                if error != sbdf_c.SBDF_OK:
+                    raise SBDFError(f"error exporting column '{column_names[i]}': "
+                                    f"{sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+
+                # Create the value array
+                value_encoding = _export_get_value_encoding(context.get_valuetype_id(), encoding_rle)
+                value_array = NULL
+                error = sbdf_c.sbdf_va_create(value_encoding, values, &value_array)
+                if value_array != NULL:
+                    _allocated_list_add(&saved_value_arrays, value_array)
+                if error != sbdf_c.SBDF_OK:
+                    raise SBDFError(f"error creating value array: {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+                sbdf_c.sbdf_obj_destroy(values)
+
+                # Create a column slice
+                col_slice = NULL
+                error = sbdf_c.sbdf_cs_create(&col_slice, value_array)
+                if col_slice != NULL:
+                    _allocated_list_add(&saved_col_slices, col_slice)
+                if error != sbdf_c.SBDF_OK:
+                    raise SBDFError(f"error creating column slice: {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+
+                # Create the invalid array
+                error, invalid_array = _export_process_invalid_array(context, row_offset, rows_per_slice, col_slice)
+                if invalid_array != NULL:
+                    _allocated_list_add(&saved_value_arrays, invalid_array)
+                if error != sbdf_c.SBDF_OK:
+                    raise SBDFError(f"error exporting invalids: {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+
+                # Add the column slice to the table slice
+                error = sbdf_c.sbdf_ts_add(col_slice, table_slice)
+                if error != sbdf_c.SBDF_OK:
+                    raise SBDFError(f"error adding column slice: {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+
+            # Write the table slice
+            error = sbdf_c.sbdf_ts_write(output_file, table_slice)
+            if error != sbdf_c.SBDF_OK:
+                raise SBDFError(f"error writing table slice: {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+            sbdf_c.sbdf_ts_destroy(table_slice)
+            _allocated_list_done(&saved_col_slices, <_allocated_dealloc_fn>sbdf_c.sbdf_cs_destroy)
+            _allocated_list_done(&saved_value_arrays, <_allocated_dealloc_fn>sbdf_c.sbdf_va_destroy)
+
+            # Next slice!
+            row_offset += rows_per_slice
+
+        # Write the end-of-table marker
+        error = sbdf_c.sbdf_ts_write_end(output_file)
+        if error != sbdf_c.SBDF_OK:
+            raise SBDFError(f"error writing end of table: {sbdf_c.sbdf_err_get_str(error).decode('utf-8')}")
+
+    finally:
+        # Close the output file
+        if output_file != NULL:
+            stdio.fclose(output_file)
diff --git a/spotfire/sbdf_helpers.c b/spotfire/sbdf_helpers.c
new file mode 100644
index 0000000..c2f97e2
--- /dev/null
+++ b/spotfire/sbdf_helpers.c
@@ -0,0 +1,188 @@
+/* Copyright (c) 2022. Cloud Software Group, Inc.
+   This file is subject to the license terms contained
+   in the license file that is distributed with this file. */
+
+#include "sbdf_helpers.h"
+#include "sbdf.h"
+
+/* Utility functions for opening FILE pointers Pythonically from ``Union[str,bytes,int]``
+ * (similar in behavior to Python's open() function).
+ */
+FILE *_pathlike_to_fileptr(PyObject *file, const char* mode) {
+    FILE *the_file = NULL;
+    int fd;
+    char *filename;
+    PyObject *filename_obj;
+
+    /* int: use the given file descriptor */
+    if(PyLong_Check(file)) {
+        fd = PyObject_AsFileDescriptor(file);
+        if(fd == -1) return NULL;
+        the_file = fdopen(fd, mode);
+    /* bytes: use the given file name */
+    } else if(PyBytes_Check(file)) {
+        filename = PyBytes_AsString(file);
+        the_file = fopen(filename, mode);
+    /* unicode/str: decode the given filename as utf-8 */
+    } else if(PyUnicode_Check(file)) {
+        if(!PyUnicode_FSConverter(file, &filename_obj)) return NULL;
+        filename = PyBytes_AsString(filename_obj);
+        the_file = fopen(filename, mode);
+        Py_XDECREF(filename_obj);
+    /* else: raise an exception */
+    } else {
+        PyErr_SetString(PyExc_TypeError, "str, bytes, or integer argument expected");
+    }
+
+    if(the_file == NULL) {
+        PyErr_SetFromErrno(PyExc_IOError);
+    }
+    return the_file;
+}
+
+/* Utility functions for managing a list of allocated C pointers and to clean them up with a specific function */
+void _allocated_list_new(struct _AllocatedList *alist, int capacity) {
+    alist->count = 0;
+    alist->capacity = capacity;
+    alist->allocated = PyMem_RawMalloc(capacity * sizeof(struct _AllocatedList));
+}
+
+void _allocated_list_add(struct _AllocatedList *alist, void *allocated) {
+    if (alist->count == alist->capacity) {
+        alist->capacity *= 2;
+        alist->allocated = PyMem_RawRealloc(alist->allocated, alist->capacity * sizeof(struct _AllocatedList));
+    }
+    alist->allocated[alist->count++] = allocated;
+}
+
+void _allocated_list_done(struct _AllocatedList *alist, _allocated_dealloc_fn fun) {
+    for(int i = 0; i < alist->count; i++) {
+        fun(alist->allocated[i]);
+        alist->allocated[i] = NULL;
+    }
+}
+
+/* Utility functions for extracting strings from Python ``Union[str,bytes]`` into C */
+sbdf_object *_export_extract_string_obj(PyObject *vals, PyObject *invalids, Py_ssize_t start, Py_ssize_t count) {
+    sbdf_object *t = calloc(1, sizeof(sbdf_object));
+
+    t->type = sbdf_vt_string();
+    t->count = (int)count;
+    char **data = (char **)calloc(count, sizeof(char *));
+    if (!data)
+    {
+        PyErr_Format(PyExc_MemoryError, "memory exhausted");
+        sbdf_obj_destroy(t);
+        return NULL;
+    }
+    t->data = data;
+
+    for(int i = 0; i < count; i++) {
+        Py_ssize_t idx = start + i;
+        PyObject *inv = PySequence_GetItem(invalids, idx);
+        if(inv == NULL) {
+            sbdf_obj_destroy(t);
+            return NULL;
+        }
+        if(PyObject_IsTrue(inv)) {
+            /* true: invalid value, add empty value to t->data */
+            data[i] = sbdf_str_create_len("", 0);
+        } else {
+            /* false: valid value, add encoded value to t->data */
+            PyObject *val = PySequence_GetItem(vals, idx);
+            if(val == NULL) {
+                Py_XDECREF(inv);
+                sbdf_obj_destroy(t);
+                return NULL;
+            }
+            PyObject *val_str = PyObject_Str(val);
+            if(val_str == NULL) {
+                Py_XDECREF(val);
+                Py_XDECREF(inv);
+                sbdf_obj_destroy(t);
+                return NULL;
+            }
+            PyObject *val_encoded = PyObject_CallMethod(val_str, "encode", "s", "utf-8");
+            if(val_encoded == NULL) {
+                Py_XDECREF(val_str);
+                Py_XDECREF(val);
+                Py_XDECREF(inv);
+                sbdf_obj_destroy(t);
+                return NULL;
+            }
+            char *val_buf;
+            Py_ssize_t val_len;
+            if(PyBytes_AsStringAndSize(val_encoded, &val_buf, &val_len) == -1) {
+                Py_XDECREF(val_encoded);
+                Py_XDECREF(val_str);
+                Py_XDECREF(val);
+                Py_XDECREF(inv);
+                sbdf_obj_destroy(t);
+                return NULL;
+            }
+            data[i] = sbdf_str_create_len(val_buf, (int)val_len);
+            Py_XDECREF(val_encoded);
+            Py_XDECREF(val_str);
+            Py_XDECREF(val);
+        }
+        Py_XDECREF(inv);
+    }
+
+    return t;
+}
+
+sbdf_object *_export_extract_binary_obj(PyObject *vals, PyObject *invalids, Py_ssize_t start, Py_ssize_t count) {
+    sbdf_object *t = calloc(1, sizeof(sbdf_object));
+
+    t->type = sbdf_vt_binary();
+    t->count = (int)count;
+    unsigned char **data = (unsigned char **)calloc(count, sizeof(unsigned char *));
+    if (!data)
+    {
+        PyErr_Format(PyExc_MemoryError, "memory exhausted");
+        sbdf_obj_destroy(t);
+        return NULL;
+    }
+    t->data = data;
+
+    for(int i = 0; i < count; i++) {
+        Py_ssize_t idx = start + i;
+        PyObject *inv = PySequence_GetItem(invalids, idx);
+        if(inv == NULL) {
+            sbdf_obj_destroy(t);
+            return NULL;
+        }
+        if(PyObject_IsTrue(inv)) {
+            /* true: invalid value, add empty value to t->data */
+            data[i] = sbdf_ba_create(0, 0);
+        } else {
+            /* false: valid value, add value to t->data */
+            PyObject *val = PySequence_GetItem(vals, idx);
+            if(val == NULL) {
+                Py_XDECREF(inv);
+                sbdf_obj_destroy(t);
+                return NULL;
+            }
+            if(!PyBytes_Check(val)) {
+                PyErr_Format(PyExc_SBDFError, "cannot convert '%S' to Spotfire Binary type; incompatible types", val);
+                Py_XDECREF(val);
+                Py_XDECREF(inv);
+                sbdf_obj_destroy(t);
+                return NULL;
+            }
+            char *val_buf;
+            Py_ssize_t val_len;
+            if(PyBytes_AsStringAndSize(val, &val_buf, &val_len) == -1) {
+                Py_XDECREF(val);
+                Py_XDECREF(inv);
+                sbdf_obj_destroy(t);
+                return NULL;
+            }
+            data[i] = sbdf_ba_create((unsigned char *)val_buf, (int)val_len);
+            Py_XDECREF(val);
+        }
+        Py_XDECREF(inv);
+    }
+
+    return t;
+}
diff --git a/spotfire/sbdf_helpers.h b/spotfire/sbdf_helpers.h
new file mode 100644
index 0000000..03dd57f
--- /dev/null
+++ b/spotfire/sbdf_helpers.h
@@ -0,0 +1,37 @@
+/* Copyright (c) 2022. Cloud Software Group, Inc.
+   This file is subject to the license terms contained
+   in the license file that is distributed with this file. */
+
+#include <Python.h>
+#include <stdio.h>
+#include <all.h>
+
+/* Utility functions for opening FILE pointers Pythonically from ``Union[str,bytes,int]``
+ * (similar in behavior to Python's open() function).
+ */
+extern FILE *_pathlike_to_fileptr(PyObject *file, const char* mode);
+
+/* Utility functions for managing a list of allocated C pointers and to clean them up with a specific function */
+struct _AllocatedList {
+    int count;
+    int capacity;
+    void **allocated;
+};
+
+typedef void(*_allocated_dealloc_fn)(void *);
+
+extern void _allocated_list_new(struct _AllocatedList *alist, int capacity);
+extern void _allocated_list_add(struct _AllocatedList *alist, void *allocated);
+extern void _allocated_list_done(struct _AllocatedList *alist, _allocated_dealloc_fn fun);
+
+/* Utility functions and definitions for managing data types */
+struct _SbdfDecimal {
+    unsigned char coeff[12];
+    unsigned char unused[2];
+    unsigned char exponent_low;
+    unsigned char exponent_high_and_sign;
+};
+
+/* Utility functions for extracting strings from Python ``Union[str,bytes]`` into C */
+extern sbdf_object *_export_extract_string_obj(PyObject *vals, PyObject *invalids, Py_ssize_t start, Py_ssize_t count);
+extern sbdf_object *_export_extract_binary_obj(PyObject *vals, PyObject *invalids, Py_ssize_t start, Py_ssize_t count);
diff --git a/spotfire/sbdf_helpers.pxi b/spotfire/sbdf_helpers.pxi
new file mode 100644
index 0000000..6727478
--- /dev/null
+++ b/spotfire/sbdf_helpers.pxi
@@ -0,0 +1,26 @@
+# Copyright © 2022. Cloud Software Group, Inc.
+# This file is subject to the license terms contained
+# in the license file that is distributed with this file.
+
+cdef extern from "sbdf_helpers.h":
+    ### Utility functions for opening FILE pointers Pythonically from ``Union[str,bytes,int]``
+    ### (similar in behavior to Python's open() function).
+    stdio.FILE* _pathlike_to_fileptr(object file, const char* mode) except NULL
+
+    ### Utility functions for managing a list of allocated C pointers and to clean them up with a specific function
+    struct _AllocatedList:
+        pass
+    ctypedef void(*_allocated_dealloc_fn)(void *)
+    void _allocated_list_new(_AllocatedList* alist, int capacity)
+    void _allocated_list_add(_AllocatedList* alist, void* allocated)
+    void _allocated_list_done(_AllocatedList* alist, _allocated_dealloc_fn fun)
+
+    ### Utility functions and definitions for managing data types
+    struct _SbdfDecimal:
+        unsigned char coeff[12]
+        unsigned char exponent_low
+        unsigned char exponent_high_and_sign
+
+    ### Utility functions for extracting strings from Python ``Union[str,bytes,int]`` into C
+    sbdf_c.sbdf_object* _export_extract_string_obj(object val, object invalids, Py_ssize_t start, Py_ssize_t count) except NULL
+    sbdf_c.sbdf_object* _export_extract_binary_obj(object val, object invalids, Py_ssize_t start, Py_ssize_t count) except NULL
diff --git a/spotfire/spk.py b/spotfire/spk.py
index c230793..508990a 100644
--- a/spotfire/spk.py
+++ b/spotfire/spk.py
@@ -609,7 +609,7 @@ def _create_module(self) -> ElementTree.Element:
 
     def _build_payload(self, metadata: ElementTree.Element, module: ElementTree.Element, payload_dest: str) -> None:
         """Build the main payload archive for the SPK package."""
-        # pylint: disable=import-outside-toplevel
+        # pylint: disable=import-outside-toplevel,no-name-in-module
         from spotfire import cabfile, codesign
         metadata_files = metadata.find("Files")
 
diff --git a/vendor/__init__.pxd b/vendor/__init__.pxd
new file mode 100644
index 0000000..e69de29
diff --git a/vendor/sbdf-c b/vendor/sbdf-c
new file mode 160000
index 0000000..8d46c4c
--- /dev/null
+++ b/vendor/sbdf-c
@@ -0,0 +1,1 @@
+Subproject commit 8d46c4c298934b490837618eab71f6b757851bd6
diff --git a/vendor/sbdf_c.pxd b/vendor/sbdf_c.pxd
new file mode 100644
index 0000000..b46de3d
--- /dev/null
+++ b/vendor/sbdf_c.pxd
@@ -0,0 +1,191 @@
+# Copyright © 2022. Cloud Software Group, Inc.
+# This file is subject to the license terms contained
+# in the license file that is distributed with this file.
+
+from libc.stdio cimport FILE
+
+cdef extern from "all.h" nogil:
+    # errors.h
+    const char* sbdf_err_get_str(int)
+    cdef enum:
+        SBDF_OK
+        SBDF_ERROR_ARGUMENT_NULL
+        SBDF_ERROR_OUT_OF_MEMORY
+        SBDF_ERROR_UNKNOWN_TYPEID
+        SBDF_ERROR_IO
+        SBDF_ERROR_UNKNOWN_VALUEARRAY_ENCODING
+        SBDF_ERROR_ARRAY_LENGTH_MUST_BE_1
+        SBDF_ERROR_METADATA_NOT_FOUND
+        SBDF_ERROR_METADATA_ALREADY_EXISTS
+        SBDF_ERROR_INCORRECT_METADATA
+        SBDF_ERROR_METADATA_READONLY
+        SBDF_ERROR_INCORRECT_COLUMNMETADATA
+        SBDF_ERROR_VALUETYPES_MUST_BE_EQUAL
+        SBDF_ERROR_UNEXPECTED_SECTION_ID
+        SBDF_ERROR_PROPERTY_ALREADY_EXISTS
+        SBDF_ERROR_PROPERTY_NOT_FOUND
+        SBDF_ERROR_INCORRECT_PROPERTY_TYPE
+        SBDF_ERROR_ROW_COUNT_MISMATCH
+        SBDF_ERROR_UNKNOWN_VERSION
+        SBDF_ERROR_COLUMN_COUNT_MISMATCH
+        SBDF_ERROR_MAGIC_NUMBER_MISSING
+        SBDF_ERROR_INVALID_SIZE
+        SBDF_TABLEEND
+        SBDF_ERROR_UNKNOWN_ERROR
+
+    # valuetypeid.h
+    cdef enum:
+        SBDF_UNKNOWNTYPEID
+        SBDF_BOOLTYPEID
+        SBDF_INTTYPEID
+        SBDF_LONGTYPEID
+        SBDF_FLOATTYPEID
+        SBDF_DOUBLETYPEID
+        SBDF_DATETIMETYPEID
+        SBDF_DATETYPEID
+        SBDF_TIMETYPEID
+        SBDF_TIMESPANTYPEID
+        SBDF_STRINGTYPEID
+        SBDF_BINARYTYPEID
+        SBDF_DECIMALTYPEID
+
+    # valuetype.h
+    struct sbdf_valuetype:
+        int id
+    int sbdf_vt_cmp(sbdf_valuetype, sbdf_valuetype)
+    sbdf_valuetype sbdf_vt_bool()
+    sbdf_valuetype sbdf_vt_int()
+    sbdf_valuetype sbdf_vt_long()
+    sbdf_valuetype sbdf_vt_float()
+    sbdf_valuetype sbdf_vt_double()
+    sbdf_valuetype sbdf_vt_datetime()
+    sbdf_valuetype sbdf_vt_date()
+    sbdf_valuetype sbdf_vt_time()
+    sbdf_valuetype sbdf_vt_timespan()
+    sbdf_valuetype sbdf_vt_string()
+    sbdf_valuetype sbdf_vt_binary()
+    sbdf_valuetype sbdf_vt_decimal()
+
+    # sbdfstring.h
+    char* sbdf_str_create(const char*)
+    char* sbdf_str_create_len(const char*, int)
+    void sbdf_str_destroy(char*)
+    int sbdf_str_len(const char*)
+    int sbdf_str_cmp(const char*, const char*)
+    char* sbdf_str_copy(const char*)
+    int sbdf_convert_utf8_to_iso88591(const char*, char*)
+    int sbdf_convert_iso88591_to_utf8(const char*, char*)
+
+    # bytearray.h
+    unsigned char* sbdf_ba_create(const unsigned char*, int)
+    void sbdf_ba_destroy(unsigned char*)
+    int sbdf_ba_get_len(const unsigned char*)
+    int sbdf_ba_memcmp(const unsigned char*, const unsigned char *)
+
+    # object.h
+    struct sbdf_object:
+        sbdf_valuetype type
+        int count
+        void* data
+    int sbdf_obj_create_arr(sbdf_valuetype, int, const void*, const int*, sbdf_object**)
+    void sbdf_obj_destroy(sbdf_object*)
+    int sbdf_obj_create(sbdf_valuetype, const void*, const int*, sbdf_object**)
+    int sbdf_obj_copy(const sbdf_object*, sbdf_object**)
+    bint sbdf_obj_eq(const sbdf_object*, const sbdf_object*)
+
+    # metadata.h
+    struct sbdf_metadata_head:
+        sbdf_metadata* first
+        int modifiable
+    struct sbdf_metadata:
+        sbdf_metadata* next
+        char* name
+        sbdf_object* value
+        sbdf_object* default_value
+    int sbdf_md_create(sbdf_metadata_head**)
+    int sbdf_md_add_str(const char*, const char*, const char*, sbdf_metadata_head*)
+    int sbdf_md_add_int(const char*, int, int, sbdf_metadata_head*)
+    int sbdf_md_add(const char*, const sbdf_object*, const sbdf_object*, sbdf_metadata_head*)
+    int sbdf_md_remove(const char*, sbdf_metadata_head*)
+    int sbdf_md_get(const char*, const sbdf_metadata_head*, sbdf_object**)
+    int sbdf_md_get_dflt(const char*, const sbdf_metadata_head*, sbdf_object**)
+    void sbdf_md_destroy(sbdf_metadata_head*)
+    int sbdf_md_cnt(const sbdf_metadata_head*)
+    bint sbdf_md_exists(const char*, const sbdf_metadata_head*)
+    int sbdf_md_copy(const sbdf_metadata_head*, sbdf_metadata_head*)
+    int sbdf_md_set_immutable(sbdf_metadata_head*)
+    int sbdf_md_compare_names(const sbdf_metadata**, const sbdf_metadata**)
+
+    # tablemetadata.h
+    struct sbdf_tablemetadata:
+        sbdf_metadata_head* table_metadata
+        int no_columns
+        sbdf_metadata_head** column_metadata
+    int sbdf_tm_create(sbdf_metadata_head*, sbdf_tablemetadata**)
+    void sbdf_tm_destroy(sbdf_tablemetadata*)
+    int sbdf_tm_add(sbdf_metadata_head*, sbdf_tablemetadata*)
+
+    # columnmetadata.h
+    int sbdf_cm_set_values(const char*, sbdf_valuetype, sbdf_metadata_head*)
+    int sbdf_cm_get_type(sbdf_metadata_head*, sbdf_valuetype*)
+    int sbdf_cm_get_name(sbdf_metadata_head*, char**)
+    char* SBDF_COLUMNMETADATA_NAME
+    char* SBDF_COLUMNMETADATA_DATATYPE
+
+    # valuearray.h
+    enum:
+        SBDF_PLAINARRAYENCODINGTYPEID
+        SBDF_RUNLENGTHENCODINGTYPEID
+        SBDF_BITARRAYENCODINGTYPEID
+    struct sbdf_valuearray:
+        pass
+    int sbdf_va_create(int, const sbdf_object*, sbdf_valuearray**)
+    int sbdf_va_create_plain(const sbdf_object*, sbdf_valuearray**)
+    int sbdf_va_create_rle(const sbdf_object*, sbdf_valuearray**)
+    int sbdf_va_create_bit(const sbdf_object*, sbdf_valuearray**)
+    void sbdf_va_destroy(sbdf_valuearray*)
+    int sbdf_va_create_dflt(const sbdf_object*, sbdf_valuearray**)
+    int sbdf_va_get_values(sbdf_valuearray*, sbdf_object**)
+    int sbdf_va_row_cnt(sbdf_valuearray*)
+
+    # columnslice.h
+    char* SBDF_ISINVALID_VALUEPROPERTY
+    char* SBDF_ERRORCODE_VALUEPROPERTY
+    char* SBDF_REPLACEDVALUE_VALUEPROPERTY
+    struct sbdf_columnslice:
+        sbdf_valuearray* values
+        int prop_cnt
+        char** property_names
+        sbdf_valuearray** properties
+        int owned
+    int sbdf_cs_create(sbdf_columnslice**, sbdf_valuearray*)
+    int sbdf_cs_add_property(sbdf_columnslice*, const char*, sbdf_valuearray*)
+    int sbdf_cs_get_property(sbdf_columnslice*, const char*, sbdf_valuearray**)
+    int sbdf_cs_row_cnt(sbdf_columnslice*)
+    void sbdf_cs_destroy(sbdf_columnslice*)
+    void sbdf_cs_destroy_all(sbdf_columnslice*)
+
+    # tableslice.h
+    struct sbdf_tableslice:
+        sbdf_tablemetadata* table_metadata
+        int no_columns
+        sbdf_columnslice** columns
+        int owned
+    int sbdf_ts_create(sbdf_tablemetadata*, sbdf_tableslice**)
+    int sbdf_ts_add(sbdf_columnslice*, sbdf_tableslice*)
+    void sbdf_ts_destroy(sbdf_tableslice*)
+
+cdef extern from "all_io.h" nogil:
+    # fileheader.h
+    int sbdf_fh_write_cur(FILE*)
+    int sbdf_fh_read(FILE*, int*, int*)
+
+    # tablemetadata_io.h
+    int sbdf_tm_read(FILE*, sbdf_tablemetadata**)
+    int sbdf_tm_write(FILE*, const sbdf_tablemetadata*)
+
+    # tableslice_io.h
+    int sbdf_ts_read(FILE*, const sbdf_tablemetadata*, char*, sbdf_tableslice**)
+    int sbdf_ts_write(FILE*, const sbdf_tableslice*)
+    int sbdf_ts_skip(FILE*, const sbdf_tablemetadata*)
+    int sbdf_ts_write_end(FILE*)
diff --git a/vendor/windows/__init__.pxd b/vendor/windows/__init__.pxd
new file mode 100644
index 0000000..c3c549a
--- /dev/null
+++ b/vendor/windows/__init__.pxd
@@ -0,0 +1,24 @@
+cdef extern from "<windows.h>" nogil:
+    # Types defined at https://docs.microsoft.com/en-us/windows/win32/winprog/windows-data-types
+    ctypedef bint BOOL
+    ctypedef const Py_UNICODE* LPCWSTR
+    ctypedef unsigned long DWORD
+    ctypedef void* HANDLE
+    ctypedef int HRESULT
+    ctypedef Py_ssize_t INT_PTR
+
+    # Preprocessor definitions defined at https://docs.microsoft.com/en-us/windows/win32/seccrypto/common-hresult-values
+    cdef enum:
+        S_OK
+
+    # Function defined at https://docs.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-getlasterror
+    cdef DWORD GetLastError()
+
+    # Function defined at https://docs.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-loadlibrarya
+    cdef HANDLE LoadLibrary(char*)
+
+    # Function defined at https://docs.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-freelibrary
+    cdef void FreeLibrary(HANDLE)
+
+    # Function defined at https://docs.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getprocaddress
+    cdef void* GetProcAddress(HANDLE, char*)
diff --git a/vendor/windows/fci.pxd b/vendor/windows/fci.pxd
new file mode 100644
index 0000000..cf6f043
--- /dev/null
+++ b/vendor/windows/fci.pxd
@@ -0,0 +1,83 @@
+from vendor cimport windows
+
+cdef extern from "<fci.h>" nogil:
+    # Enumeration for FCI errors
+    cdef enum FCIERROR:
+        FCIERR_NONE
+        FCIERR_OPEN_SRC
+        FCIERR_READ_SRC
+        FCIERR_ALLOC_FAIL
+        FCIERR_TEMP_FILE
+        FCIERR_BAD_COMPR_TYPE
+        FCIERR_CAB_FILE
+        FCIERR_USER_ABORT
+        FCIERR_MCI_FAIL
+        FCIERR_CAB_FORMAT_LIMIT
+
+    # Type for FCI context
+    ctypedef void* HFCI
+
+    # Structure defined at https://learn.microsoft.com/en-us/windows/win32/api/fdi_fci_types/ns-fdi_fci_types-erf
+    ctypedef struct ERF:
+        int erfOper
+        int erfType
+        windows.BOOL fError
+
+    # Preprocessor definitions for CCAB type
+    cdef enum:
+        CB_MAX_DISK_NAME
+        CB_MAX_CABINET_NAME
+        CB_MAX_CAB_PATH
+
+    # Structure defined at https://learn.microsoft.com/en-us/windows/win32/api/fci/ns-fci-ccab
+    ctypedef struct CCAB:
+        unsigned long cb
+        unsigned long cbFolderThresh
+        unsigned int cbReserveCFHeader
+        unsigned int cbReserveCFFolder
+        unsigned int cbReserveCFData
+        int iCab
+        int iDisk
+        bint fFailOnIncompressible
+        unsigned short setID
+        char szDisk[CB_MAX_DISK_NAME]
+        char szCab[CB_MAX_CABINET_NAME]
+        char szCabPath[CB_MAX_CAB_PATH]
+
+    # Types for FCI callbacks
+    ctypedef void* (*PFNFCIALLOC)(unsigned long)
+    ctypedef void (*PFNFCIFREE)(void*)
+    ctypedef windows.INT_PTR (*PFNFCIOPEN)(char*, int, int, int*, void*)
+    ctypedef unsigned int (*PFNFCIREAD)(windows.INT_PTR, void*, unsigned int, int*, void*)
+    ctypedef unsigned int (*PFNFCIWRITE)(windows.INT_PTR, void*, unsigned int, int *, void*)
+    ctypedef int (*PFNFCICLOSE)(windows.INT_PTR, int*, void*)
+    ctypedef long (*PFNFCISEEK)(windows.INT_PTR, long, int, int*, void*)
+    ctypedef int (*PFNFCIDELETE)(char*, int*, void*)
+    ctypedef windows.BOOL (*PFNFCIGETNEXTCABINET)(CCAB*, unsigned long, void*)
+    ctypedef int (*PFNFCIFILEPLACED)(CCAB*, char*, long, windows.BOOL, void*)
+    ctypedef windows.INT_PTR (*PFNFCIGETOPENINFO)(char*, unsigned short*, unsigned short*, unsigned short*, int*, void*)
+    ctypedef long (*PFNFCISTATUS)(unsigned int, unsigned long, unsigned long, void*)
+    ctypedef windows.BOOL (*PFNFCIGETTEMPFILE)(char*, int, void*)
+
+    # Function defined at https://learn.microsoft.com/en-us/windows/win32/api/fci/nf-fci-fcicreate
+    cdef HFCI FCICreate(ERF*, PFNFCIFILEPLACED, PFNFCIALLOC, PFNFCIFREE, PFNFCIOPEN, PFNFCIREAD, PFNFCIWRITE, PFNFCICLOSE, PFNFCISEEK, PFNFCIDELETE, PFNFCIGETTEMPFILE, CCAB*, void*)
+
+    # Type for FCI compression types
+    ctypedef unsigned short TCOMP
+
+    # Preprocessor definitions for FCI compression types
+    cdef enum:
+        tcompTYPE_NONE
+        tcompTYPE_MSZIP
+
+    # Function defined at https://learn.microsoft.com/en-us/windows/win32/api/fci/nf-fci-fciaddfile
+    cdef windows.BOOL FCIAddFile(HFCI, char*, char*, windows.BOOL, PFNFCIGETNEXTCABINET, PFNFCISTATUS, PFNFCIGETOPENINFO, TCOMP)
+
+    # Function defined at https://learn.microsoft.com/en-us/windows/win32/api/fci/nf-fci-fciflushcabinet
+    cdef windows.BOOL FCIFlushCabinet(HFCI, windows.BOOL, PFNFCIGETNEXTCABINET, PFNFCISTATUS)
+
+    # Function defined at https://learn.microsoft.com/en-us/windows/win32/api/fci/nf-fci-fciflushfolder
+    cdef windows.BOOL FCIFlushFolder(HFCI, PFNFCIGETNEXTCABINET, PFNFCISTATUS)
+
+    # Function defined at https://learn.microsoft.com/en-us/windows/win32/api/fci/nf-fci-fcidestroy
+    cdef windows.BOOL FCIDestroy(HFCI)
diff --git a/vendor/windows/mssign32.h b/vendor/windows/mssign32.h
new file mode 100644
index 0000000..4dcf8d1
--- /dev/null
+++ b/vendor/windows/mssign32.h
@@ -0,0 +1,144 @@
+#ifndef _MSSIGN32_H
+#define _MSSIGN32_H
+
+// This file was created from various MSDN articles, as Microsoft does
+// not define the signing structures and functions in a header file
+// included in the Windows SDK.
+
+// Required structures that are not defined in a public header file
+typedef struct _SIGNER_FILE_INFO {
+    DWORD cbSize;
+    LPCWSTR pwszFileName;
+    HANDLE hFile;
+} SIGNER_FILE_INFO, *PSIGNER_FILE_INFO;
+
+typedef struct _SIGNER_BLOB_INFO {
+    DWORD cbSize;
+    GUID *pGuidSubject;
+    DWORD cbBlob;
+    BYTE *pbBlob;
+    LPCWSTR pwszDisplayName;
+} SIGNER_BLOB_INFO, *PSIGNER_BLOB_INFO;
+
+typedef struct _SIGNER_SUBJECT_INFO {
+    DWORD cbSize;
+    DWORD *pdwIndex;
+    DWORD dwSubjectChoice;
+    union {
+        SIGNER_FILE_INFO *pSignerFileInfo;
+        SIGNER_BLOB_INFO *pSignerBlobInfo;
+    };
+} SIGNER_SUBJECT_INFO, *PSIGNER_SUBJECT_INFO;
+
+#define SIGNER_SUBJECT_FILE 1
+
+typedef struct _SIGNER_CERT_STORE_INFO {
+    DWORD cbSize;
+    PCCERT_CONTEXT pSigningCert;
+    DWORD dwCertPolicy;
+    HCERTSTORE hCertStore;
+} SIGNER_CERT_STORE_INFO, *PSIGNER_CERT_STORE_INFO;
+
+#define SIGNER_CERT_POLICY_CHAIN 2
+
+typedef struct _SIGNER_SPC_CHAIN_INFO {
+    DWORD cbSize;
+    LPCWSTR pwszSpcFile;
+    DWORD dwCertPolicy;
+    HCERTSTORE hCertStore;
+} SIGNER_SPC_CHAIN_INFO, *PSIGNER_SPC_CHAIN_INFO;
+
+typedef struct _SIGNER_CERT {
+    DWORD cbSize;
+    DWORD dwCertChoice;
+    union {
+        LPCWSTR pwszSpcFile;
+        SIGNER_CERT_STORE_INFO *pCertStoreInfo;
+        SIGNER_SPC_CHAIN_INFO *pSpcChainInfo;
+    };
+    HWND hwnd;
+} SIGNER_CERT, *PSIGNER_CERT;
+
+#define SIGNER_CERT_STORE 2
+
+typedef struct _SIGNER_ATTR_AUTHCODE {
+    DWORD cbSize;
+    BOOL fCommercial;
+    BOOL fIndividual;
+    LPCWSTR pwszName;
+    LPCWSTR pwszInfo;
+} SIGNER_ATTR_AUTHCODE, *PSIGNER_ATTR_AUTHCODE;
+
+typedef struct _SIGNER_SIGNATURE_INFO {
+    DWORD cbSize;
+    ALG_ID algidHash;
+    DWORD dwAttrChoice;
+    union {
+        SIGNER_ATTR_AUTHCODE *pAttrAuthcode;
+    };
+    PCRYPT_ATTRIBUTES psAuthenticated;
+    PCRYPT_ATTRIBUTES psUnauthenticated;
+} SIGNER_SIGNATURE_INFO, *PSIGNER_SIGNATURE_INFO;
+
+#define SIGNER_AUTHCODE_ATTR 1
+
+typedef struct _SIGNER_PROVIDER_INFO {
+    DWORD cbSize;
+    LPCWSTR pwszProviderName;
+    DWORD dwProviderType;
+    DWORD dwKeySpec;
+    DWORD dwPvkChoice;
+    union {
+        LPWSTR pwszPvkFileName;
+        LPWSTR pwszKeyContainer;
+    };
+} SIGNER_PROVIDER_INFO, *PSIGNER_PROVIDER_INFO;
+
+typedef struct _SIGNER_CONTEXT {
+    DWORD cbSize;
+    DWORD cbBlob;
+    BYTE *pbBlob;
+} SIGNER_CONTEXT, *PSIGNER_CONTEXT;
+
+typedef HRESULT(WINAPI* SignerFreeSignerContextType)(
+    __in  SIGNER_CONTEXT *pSignerContext
+    );
+
+typedef HRESULT(WINAPI *SignerSignExType)(
+    __in      DWORD dwFlags,
+    __in      SIGNER_SUBJECT_INFO *pSubjectInfo,
+    __in      SIGNER_CERT *pSignerCert,
+    __in      SIGNER_SIGNATURE_INFO *pSignatureInfo,
+    __in_opt  SIGNER_PROVIDER_INFO *pProviderInfo,
+    __in_opt  LPCWSTR pwszHttpTimeStamp,
+    __in_opt  PCRYPT_ATTRIBUTES psRequest,
+    __in_opt  LPVOID pSipData,
+    __out     SIGNER_CONTEXT **ppSignerContext
+    );
+
+typedef HRESULT(WINAPI *SignerTimeStampType)(
+    __in      SIGNER_SUBJECT_INFO  *pSubjectInfo,
+    __in      LPCWSTR              pwszHttpTimeStamp,
+    __in_opt  PCRYPT_ATTRIBUTES    psRequest,
+    __in_opt  LPVOID               pSipData
+    );
+
+// This function is currently documented incorrectly on MSDN.
+// Source: http://stackoverflow.com/a/34367072
+typedef HRESULT(WINAPI *SignerTimeStampEx2Type)(
+    __in      DWORD                  dwFlags,
+    __in      SIGNER_SUBJECT_INFO    *pSubjectInfo,
+    __in      PCWSTR                 pwszHttpTimeStamp,
+    __in      PCSTR                  pszTimeStampAlgorithmOid,
+    __in_opt  PCRYPT_ATTRIBUTES      psRequest,
+    __in_opt  PVOID                  pSipData,
+    __out     SIGNER_CONTEXT         **ppSignerContext
+    );
+
+#define SIGNER_TIMESTAMP_AUTHENTICODE 1
+#define SIGNER_TIMESTAMP_RFC3161 2
+
+#define OID_SHA1 "1.3.14.3.2.26"
+#define OID_SHA256 "2.16.840.1.101.3.4.2.1"
+
+#endif
diff --git a/vendor/windows/mssign32.pxd b/vendor/windows/mssign32.pxd
new file mode 100644
index 0000000..7e80313
--- /dev/null
+++ b/vendor/windows/mssign32.pxd
@@ -0,0 +1,97 @@
+from vendor cimport windows
+from vendor.windows cimport wincrypt
+
+# Definitions for mssign32.dll
+cdef extern from "mssign32.h" nogil:
+    # Structure defined at https://docs.microsoft.com/en-us/windows/win32/seccrypto/signer-file-info
+    ctypedef struct SIGNER_FILE_INFO:
+        windows.DWORD cbSize
+        windows.LPCWSTR pwszFileName
+        windows.HANDLE hFile
+
+    # Structure defined at https://docs.microsoft.com/en-us/windows/win32/seccrypto/signer-subject-info
+    ctypedef struct SIGNER_SUBJECT_INFO:
+        windows.DWORD cbSize
+        windows.DWORD* pdwIndex
+        windows.DWORD dwSubjectChoice
+        SIGNER_FILE_INFO* pSignerFileInfo
+
+    # Preprocessor defines for SIGNER_SUBJECT_INFO structure
+    cdef enum:
+        SIGNER_SUBJECT_FILE
+
+    # Structure defined at https://docs.microsoft.com/en-us/windows/win32/seccrypto/signer-cert-store-info
+    ctypedef struct SIGNER_CERT_STORE_INFO:
+        windows.DWORD cbSize
+        wincrypt.CERT_CONTEXT* pSigningCert
+        windows.DWORD dwCertPolicy
+        windows.HANDLE hCertStore
+
+    # Preprocessor defines for SIGNER_CERT_STORE_INFO structure
+    cdef enum:
+        SIGNER_CERT_POLICY_CHAIN
+
+    # Structure defined at https://docs.microsoft.com/en-us/windows/win32/seccrypto/signer-cert
+    ctypedef struct SIGNER_CERT:
+        windows.DWORD cbSize
+        windows.DWORD dwCertChoice
+        SIGNER_CERT_STORE_INFO* pCertStoreInfo
+        windows.HANDLE hwnd
+
+    # Preprocessor defines for SIGNER_CERT structure
+    cdef enum:
+        SIGNER_CERT_STORE
+
+    # Structure defined at https://docs.microsoft.com/en-us/windows/win32/seccrypto/signer-attr-authcode
+    ctypedef struct SIGNER_ATTR_AUTHCODE:
+        windows.DWORD cbSize
+        windows.BOOL fCommercial
+        windows.BOOL fIndividual
+        windows.LPCWSTR pwszName
+        windows.LPCWSTR pwszInfo
+
+    # Structure defined at https://docs.microsoft.com/en-us/windows/win32/seccrypto/signer-signature-info
+    ctypedef struct SIGNER_SIGNATURE_INFO:
+        windows.DWORD cbSize
+        wincrypt.ALG_ID algidHash
+        windows.DWORD dwAttrChoice
+        SIGNER_ATTR_AUTHCODE* pAttrAuthcode
+        wincrypt.CRYPT_ATTRIBUTES* psAuthenticated
+        wincrypt.CRYPT_ATTRIBUTES* psUnauthenticated
+
+    # Preprocessor defines for SIGNER_SIGNATURE_INFO structure
+    cdef enum:
+        SIGNER_AUTHCODE_ATTR
+
+    # Structure defined at https://docs.microsoft.com/en-us/windows/win32/seccrypto/signer-provider-info
+    ctypedef struct SIGNER_PROVIDER_INFO:
+        pass
+
+    # Structure defined at https://docs.microsoft.com/en-us/windows/win32/seccrypto/signer-context
+    ctypedef struct SIGNER_CONTEXT:
+        pass
+
+    # Function defined at https://docs.microsoft.com/en-us/windows/win32/seccrypto/signersignex
+    ctypedef windows.HRESULT (*SignerSignExType)(windows.DWORD, SIGNER_SUBJECT_INFO*, SIGNER_CERT*,
+                                                 SIGNER_SIGNATURE_INFO*, SIGNER_PROVIDER_INFO*, windows.LPCWSTR,
+                                                 wincrypt.CRYPT_ATTRIBUTES*, void*, SIGNER_CONTEXT**)
+
+    # Function defined at https://docs.microsoft.com/en-us/windows/win32/seccrypto/signertimestamp
+    ctypedef windows.HRESULT (*SignerTimeStampType)(SIGNER_SUBJECT_INFO*, windows.LPCWSTR, wincrypt.CRYPT_ATTRIBUTES*,
+                                                    void*)
+
+    # Function defined at https://docs.microsoft.com/en-us/windows/win32/seccrypto/signertimestampex2
+    ctypedef windows.HRESULT (*SignerTimeStampEx2Type)(windows.DWORD, SIGNER_SUBJECT_INFO*, windows.LPCWSTR, char*,
+                                                       wincrypt.CRYPT_ATTRIBUTES*, void*, SIGNER_CONTEXT**)
+
+    # Preprocessor defines for SignerTimeStampEx2 function arguments
+    cdef enum:
+        SIGNER_TIMESTAMP_AUTHENTICODE
+        SIGNER_TIMESTAMP_RFC3161
+
+    # Preprocessor defines for hash algorithm OID strings
+    char* OID_SHA1
+    char* OID_SHA256
+
+    # Function defined at https://docs.microsoft.com/en-us/windows/win32/seccrypto/signerfreesignercontext
+    ctypedef windows.HRESULT (*SignerFreeSignerContextType)(SIGNER_CONTEXT*)
diff --git a/vendor/windows/wincrypt.pxd b/vendor/windows/wincrypt.pxd
new file mode 100644
index 0000000..77929c1
--- /dev/null
+++ b/vendor/windows/wincrypt.pxd
@@ -0,0 +1,57 @@
+from vendor cimport windows
+
+# Definitions for crypt32.dll
+cdef extern from "<wincrypt.h>" nogil:
+    # Preprocessor definitions for certificate encoding types
+    cdef enum:
+        X509_ASN_ENCODING
+        PKCS_7_ASN_ENCODING
+
+    # Structure defined at https://docs.microsoft.com/en-us/windows/win32/api/wincrypt/ns-wincrypt-cert_context
+    ctypedef struct CERT_CONTEXT:
+        pass
+
+    # Preprocessor definitions for CertGetCertificateContextProperty function
+    cdef enum:
+        CERT_KEY_SPEC_PROP_ID
+
+    # Type defined at https://docs.microsoft.com/en-us/windows/win32/seccrypto/alg-id
+    ctypedef unsigned int ALG_ID
+
+    # Preprocessor definitions for ALG_ID type
+    cdef enum:
+        CALG_SHA1
+        CALG_SHA_256
+
+    # Structure defined at https://docs.microsoft.com/en-us/windows/win32/api/Wincrypt/ns-wincrypt-crypt_attributes
+    ctypedef struct CRYPT_ATTRIBUTES:
+        pass
+
+    # Structure defined at https://docs.microsoft.com/en-us/previous-versions/windows/desktop/legacy/aa381414(v=vs.85)
+    ctypedef struct CRYPT_DATA_BLOB:
+        windows.DWORD cbData
+        char* pbData
+
+    # Function defined at https://docs.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-pfximportcertstore
+    windows.HANDLE PFXImportCertStore(CRYPT_DATA_BLOB*, windows.LPCWSTR, windows.DWORD)
+
+    # Function defined at https://docs.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certclosestore
+    windows.BOOL CertCloseStore(windows.HANDLE, windows.DWORD)
+
+    # Preprocessor definitions for CertCloseStore function
+    cdef enum:
+        CERT_CLOSE_STORE_CHECK_FLAG
+
+    # Function defined at https://docs.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certfindcertificateinstore
+    const CERT_CONTEXT* CertFindCertificateInStore(windows.HANDLE, windows.DWORD, windows.DWORD, windows.DWORD, void*,
+                                                   CERT_CONTEXT*)
+
+    # Preprocessor definitions for CertFindCertificateInStore
+    cdef enum:
+        CERT_FIND_ANY
+
+    # Function defined at https://docs.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certgetcertificatecontextproperty
+    windows.BOOL CertGetCertificateContextProperty(CERT_CONTEXT*, windows.DWORD, void*, windows.DWORD*)
+
+    # Function defined at https://docs.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certfreecertificatecontext
+    windows.BOOL CertFreeCertificateContext(CERT_CONTEXT*)


Test patch:
diff --git a/spotfire/test/__main__.py b/spotfire/test/__main__.py
index 92d8ff1..a0ea3ec 100644
--- a/spotfire/test/__main__.py
+++ b/spotfire/test/__main__.py
@@ -3,6 +3,7 @@
 $ python -m spotfire.test
 """
 
+import os
 import unittest
 import xmlrunner
 
@@ -20,6 +21,6 @@ def load_tests(loader, tests, pattern):  # pylint: disable=unused-argument
     return test_suite
 
 
-with open(f"results-{utils.PYTHON_VERSION}.xml", "wb") as output:
+with open(f"results-{os.environ.get('RESULTS_NAME', utils.PYTHON_VERSION)}.xml", "wb") as output:
     unittest.main(testRunner=xmlrunner.XMLTestRunner(output=output, resultclass=utils.PythonVersionModifiedResult),
                   failfast=False, buffer=False, catchbreak=False)
diff --git a/spotfire/test/test_data_function.py b/spotfire/test/test_data_function.py
index 075f73c..9a365fe 100644
--- a/spotfire/test/test_data_function.py
+++ b/spotfire/test/test_data_function.py
@@ -162,6 +162,18 @@ def expected():
                  ^
 SyntaxError: invalid syntax (<data_function>, line 1)
 
+Traceback (most recent call last):
+  File "data_function.py", in _compile_script
+    self.compiled_script = compile(self.script, '<data_function>', 'exec')
+"""
+            elif sys.version_info.major == 3 and sys.version_info.minor < 11:
+                return """Error executing Python script:
+
+  File "<data_function>", line 1
+    rais TypeError('from test_syntax_error')
+         ^
+SyntaxError: invalid syntax (<data_function>, line 1)
+
 Traceback (most recent call last):
   File "data_function.py", in _compile_script
     self.compiled_script = compile(self.script, '<data_function>', 'exec')
@@ -177,6 +189,7 @@ def expected():
 Traceback (most recent call last):
   File "data_function.py", in _compile_script
     self.compiled_script = compile(self.script, '<data_function>', 'exec')
+                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 """
 
         self._run_analytic("rais TypeError('from test_syntax_error')", {}, {}, False, expected)
@@ -193,6 +206,18 @@ def expected():
           ^
 SyntaxError: invalid syntax (<data_function>, line 1)
 
+Traceback (most recent call last):
+  File "data_function.py", in _compile_script
+    self.compiled_script = compile(self.script, '<data_function>', 'exec')
+"""
+            elif sys.version_info.major == 3 and sys.version_info.minor < 11:
+                return """Error executing Python script:
+
+  File "<data_function>", line 1
+    if + 42
+          ^
+SyntaxError: expected ':' (<data_function>, line 1)
+
 Traceback (most recent call last):
   File "data_function.py", in _compile_script
     self.compiled_script = compile(self.script, '<data_function>', 'exec')
@@ -208,12 +233,17 @@ def expected():
 Traceback (most recent call last):
   File "data_function.py", in _compile_script
     self.compiled_script = compile(self.script, '<data_function>', 'exec')
+                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 """
+
         self._run_analytic("if + 42", {}, {}, False, expected)
 
     def test_syntax_error_c(self):
         """Run the syntax error test provided in pysrv122"""
-        self._run_analytic("whille x%2 == 0:", {}, {}, False, """Error executing Python script:
+        def expected():
+            # pylint: disable=no-else-return
+            if sys.version_info.major == 3 and sys.version_info.minor < 11:
+                return """Error executing Python script:
 
   File "<data_function>", line 1
     whille x%2 == 0:
@@ -223,12 +253,29 @@ def test_syntax_error_c(self):
 Traceback (most recent call last):
   File "data_function.py", in _compile_script
     self.compiled_script = compile(self.script, '<data_function>', 'exec')
-""")
+"""
+            else:
+                return """Error executing Python script:
+
+  File "<data_function>", line 1
+    whille x%2 == 0:
+           ^
+SyntaxError: invalid syntax (<data_function>, line 1)
+
+Traceback (most recent call last):
+  File "data_function.py", in _compile_script
+    self.compiled_script = compile(self.script, '<data_function>', 'exec')
+                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+"""
+
+        self._run_analytic("whille x%2 == 0:", {}, {}, False, expected)
 
     def test_indentation_error(self):
         """Run the syntax error test provided in pysrv122"""
-        self._run_analytic("     print('You have entered an even number.')",
-                           {}, {}, False, """Error executing Python script:
+        def expected():
+            # pylint: disable=no-else-return
+            if sys.version_info.major == 3 and sys.version_info.minor < 11:
+                return """Error executing Python script:
 
   File "<data_function>", line 1
          print('You have entered an even number.')
@@ -238,7 +285,22 @@ def test_indentation_error(self):
 Traceback (most recent call last):
   File "data_function.py", in _compile_script
     self.compiled_script = compile(self.script, '<data_function>', 'exec')
-""")
+"""
+            else:
+                return """Error executing Python script:
+
+  File "<data_function>", line 1
+         print('You have entered an even number.')
+         ^
+IndentationError: unexpected indent (<data_function>, line 1)
+
+Traceback (most recent call last):
+  File "data_function.py", in _compile_script
+    self.compiled_script = compile(self.script, '<data_function>', 'exec')
+                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+"""
+
+        self._run_analytic("     print('You have entered an even number.')", {}, {}, False, expected)
 
     def test_print(self):
         """Test a data function that prints."""
diff --git a/spotfire/test/test_sbdf.py b/spotfire/test/test_sbdf.py
index 5e399d2..96a7d80 100644
--- a/spotfire/test/test_sbdf.py
+++ b/spotfire/test/test_sbdf.py
@@ -2,16 +2,17 @@
 
 import datetime
 import decimal
-import os
 import unittest
 import tempfile
 import pkg_resources
 
 import pandas
+import pandas.testing
 import geopandas
 
 import spotfire
 from spotfire import sbdf
+from spotfire.test import utils
 
 
 class SbdfTest(unittest.TestCase):
@@ -20,7 +21,7 @@ class SbdfTest(unittest.TestCase):
 
     def test_read_0(self):
         """Reading simple SBDF files should work."""
-        dataframe = sbdf.import_data(f"{os.path.dirname(__file__)}/files/sbdf/0.sbdf")
+        dataframe = sbdf.import_data(utils.get_test_data_file("sbdf/0.sbdf"))
         self.assertEqual(dataframe.shape, (0, 12))
 
         def verify(dict_, pre, post):
@@ -56,7 +57,7 @@ def verify(dict_, pre, post):
 
     def test_read_1(self):
         """Reading simple SBDF files should work."""
-        dataframe = sbdf.import_data(f"{os.path.dirname(__file__)}/files/sbdf/1.sbdf")
+        dataframe = sbdf.import_data(utils.get_test_data_file("sbdf/1.sbdf"))
         self.assertEqual(dataframe.shape, (1, 12))
         self.assertEqual(dataframe.at[0, "Boolean"], False)
         self.assertEqual(dataframe.at[0, "Integer"], 69)
@@ -72,7 +73,7 @@ def test_read_1(self):
 
     def test_read_100(self):
         """Reading simple SBDF files should work."""
-        dataframe = sbdf.import_data(f"{os.path.dirname(__file__)}/files/sbdf/100.sbdf")
+        dataframe = sbdf.import_data(utils.get_test_data_file("sbdf/100.sbdf"))
         self.assertEqual(dataframe.shape, (100, 12))
         self.assertEqual(dataframe.get("Boolean")[0:6].tolist(), [False, True, None, False, True, None])
         self.assertEqual(dataframe.get("Integer")[0:6].dropna().tolist(), [69.0, 73.0, 75.0, 79.0])
@@ -90,7 +91,7 @@ def test_read_100(self):
 
     def test_read_10001(self):
         """Reading simple SBDF files should work."""
-        dataframe = sbdf.import_data(f"{os.path.dirname(__file__)}/files/sbdf/10001.sbdf")
+        dataframe = sbdf.import_data(utils.get_test_data_file("sbdf/10001.sbdf"))
         self.assertEqual(dataframe.shape, (10001, 12))
         # Check the values in the first row
         self.assertEqual(dataframe.at[0, "Boolean"], False)
@@ -119,7 +120,7 @@ def test_read_10001(self):
 
     def test_read_write_geodata(self):
         """Test that geo-encoded data is properly converted to/from GeoDataFrame"""
-        gdf = sbdf.import_data(f"{os.path.dirname(__file__)}/files/sbdf/NACountries.sbdf")
+        gdf = sbdf.import_data(utils.get_test_data_file("sbdf/NACountries.sbdf"))
         self.assertIsInstance(gdf, pandas.DataFrame)
         self.assertIsInstance(gdf, geopandas.GeoDataFrame)
 
@@ -142,7 +143,7 @@ def test_read_write_geodata(self):
 
     def test_write_unicode(self):
         """Test that unicode string arrays are properly written"""
-        udf = sbdf.import_data(f"{os.path.dirname(__file__)}/files/sbdf/unicode.sbdf")
+        udf = sbdf.import_data(utils.get_test_data_file("sbdf/unicode.sbdf"))
         with tempfile.TemporaryDirectory() as tempdir:
             sbdf.export_data(udf, f"{tempdir}/test.sbdf")
             udf2 = sbdf.import_data(f"{tempdir}/test.sbdf")
@@ -151,11 +152,11 @@ def test_write_unicode(self):
 
     def test_read_write_alltypes(self):
         """Test that all data types can be properly roundtripped read/write"""
-        dataframe = sbdf.import_data(f"{os.path.dirname(__file__)}/files/sbdf/alltypes.sbdf")
+        dataframe = sbdf.import_data(utils.get_test_data_file("sbdf/alltypes.sbdf"))
         with tempfile.TemporaryDirectory() as tempdir:
             sbdf.export_data(dataframe, f"{tempdir}/test.sbdf")
             df2 = sbdf.import_data(f"{tempdir}/test.sbdf")
-            self.assertTrue(dataframe.equals(df2))
+            pandas.testing.assert_frame_equal(dataframe, df2)
 
     def test_write_nullable_dtypes(self):
         """We should be able to write all nullable column dtypes."""
@@ -177,7 +178,7 @@ def test_write_nullable_dtypes(self):
 
     def test_get_spotfire_types(self):
         """All types should be reported properly"""
-        dataframe = sbdf.import_data(f"{os.path.dirname(__file__)}/files/sbdf/alltypes.sbdf")
+        dataframe = sbdf.import_data(utils.get_test_data_file("sbdf/alltypes.sbdf"))
         type_names = spotfire.get_spotfire_types(dataframe)
         self.assertEqual(type_names["ColumnBoolean"], "Boolean")
         self.assertEqual(type_names["ColumnDate"], "Date")
@@ -194,7 +195,7 @@ def test_get_spotfire_types(self):
 
     def test_set_spotfire_types(self):
         """Setting SBDF types should work properly"""
-        dataframe = sbdf.import_data(f"{os.path.dirname(__file__)}/files/sbdf/alltypes.sbdf")
+        dataframe = sbdf.import_data(utils.get_test_data_file("sbdf/alltypes.sbdf"))
         # set a single column by name
         spotfire.set_spotfire_types(dataframe, {"ColumnLong": "Integer"})
         self.assertEqual(spotfire.get_spotfire_types(dataframe)["ColumnLong"], "Integer")
@@ -238,12 +239,12 @@ def test_set_spotfire_types(self):
 
     def test_import_export_alltypes(self):
         """Verify all types properly export and re-import with the proper Spotfire type"""
-        dataframe = sbdf.import_data(f"{os.path.dirname(__file__)}/files/sbdf/alltypes.sbdf")
+        dataframe = sbdf.import_data(utils.get_test_data_file("sbdf/alltypes.sbdf"))
         with tempfile.TemporaryDirectory() as tempdir:
             sbdf.export_data(dataframe, f"{tempdir}/output.sbdf")
             new_df = sbdf.import_data(f"{tempdir}/output.sbdf")
-        self.assertTrue(dataframe.equals(new_df))
-        self.assertTrue(spotfire.get_spotfire_types(dataframe).equals(spotfire.get_spotfire_types(new_df)))
+        pandas.testing.assert_frame_equal(dataframe, new_df)
+        pandas.testing.assert_series_equal(spotfire.get_spotfire_types(dataframe), spotfire.get_spotfire_types(new_df))
 
     def test_invalid_export_type(self):
         """Verify invalid export types are ignored"""
@@ -267,6 +268,23 @@ def test_import_export_string(self):
                       "Integer", "LongInteger", "SingleReal", "Real", "Binary"]
         self.verify_import_export_types(data, default_type, pass_types, fail_types)
 
+    def test_import_export_binary(self):
+        """Verify binary column conversions"""
+        data = [b"apple", b"banana", b"cherry"]
+        default_type = "Binary"
+        pass_types = ["String", "Binary", "Boolean"]
+        fail_types = ["DateTime", "Date", "Time", "TimeSpan", "Currency",
+                      "Integer", "LongInteger", "SingleReal", "Real"]
+        self.verify_import_export_types(data, default_type, pass_types, fail_types)
+
+    def test_import_export_boolean(self):
+        """Verify boolean column conversions"""
+        data = [True, False]
+        default_type = "Boolean"
+        pass_types = ["String", "Boolean", "Integer", "LongInteger", "SingleReal", "Real"]
+        fail_types = ["DateTime", "Date", "Time", "TimeSpan", "Binary", "Currency"]
+        self.verify_import_export_types(data, default_type, pass_types, fail_types)
+
     def test_import_export_integer(self):
         """Verify integer column conversions"""
         data = [1, 2, 3]
@@ -275,6 +293,14 @@ def test_import_export_integer(self):
         fail_types = ["DateTime", "Date", "Time", "TimeSpan", "Binary", "Currency"]
         self.verify_import_export_types(data, default_type, pass_types, fail_types)
 
+    def test_import_export_float(self):
+        """Verify float column conversions"""
+        data = [1., 2., 3.5]
+        default_type = "Real"
+        pass_types = ["String", "Boolean", "Integer", "LongInteger", "SingleReal", "Real"]
+        fail_types = ["DateTime", "Date", "Time", "TimeSpan", "Binary", "Currency"]
+        self.verify_import_export_types(data, default_type, pass_types, fail_types)
+
     def test_import_export_datetime(self):
         """Verify datetime column conversions"""
         data = [datetime.datetime.now(), datetime.datetime(1979, 10, 23, 5, 32, 00)]
@@ -309,6 +335,14 @@ def test_import_export_timespan(self):
         fail_types = ["Binary", "Currency", "DateTime", "Date", "Time", "Integer", "LongInteger", "SingleReal", "Real"]
         self.verify_import_export_types(data, default_type, pass_types, fail_types)
 
+    def test_import_export_currency(self):
+        """Verify currency/decimal column conversions"""
+        data = [decimal.Decimal("123.45"), decimal.Decimal("67.890")]
+        default_type = "Currency"
+        pass_types = ["String", "Currency", "Boolean"]
+        fail_types = ["Binary", "DateTime", "Date", "Time", "TimeSpan", "Integer", "LongInteger", "SingleReal", "Real"]
+        self.verify_import_export_types(data, default_type, pass_types, fail_types)
+
     def test_import_export_missing(self):
         """Verify column with all missing values can be coerced to anything"""
         data = [None, None, None]
@@ -331,8 +365,9 @@ def verify_import_export_types(self, data, default_type, pass_types, fail_types)
             self.assertEqual(new_df_types["x"], default_type)
         # if default is None expect failure
         else:
-            with self.assertRaises(sbdf.SBDFError):
-                sbdf.export_data(dataframe, "output.sbdf")
+            with tempfile.TemporaryDirectory() as tempdir:
+                with self.assertRaises(sbdf.SBDFError):
+                    sbdf.export_data(dataframe, f"{tempdir}/output.sbdf")
 
         # validate expected passing cases
         for df_type in pass_types:
diff --git a/spotfire/test/utils.py b/spotfire/test/utils.py
index ac6abec..d58b85c 100644
--- a/spotfire/test/utils.py
+++ b/spotfire/test/utils.py
@@ -1,6 +1,8 @@
 """Utility classes for integrating unit test results into Jenkins."""
 
 
+import inspect
+import os
 import sys
 import xml.dom.minidom
 import xmlrunner
@@ -38,3 +40,19 @@ def generate_reports(self, test_runner):
 
 
 PYTHON_VERSION = f"py{sys.version_info.major}{sys.version_info.minor}"
+
+
+def get_test_data_file(name):
+    """Determine the filename of a test data file.  It is possible for the data files to be separated from the
+    ``spotfire.test.files`` package, especially for testing binary wheels in the CI environment; to look in an
+    alternate location, set the ``TEST_FILES_DIR`` environment variable.
+
+    :param name: the basename of the test file
+    :return: the full filename of the test file
+    """
+    test_dir, _ = os.path.split(inspect.stack()[1].filename)
+    if "TEST_FILES_DIR" in os.environ:
+        files_dir = os.getenv("TEST_FILES_DIR")
+    else:
+        files_dir = os.path.join(test_dir, "files")
+    return os.path.join(files_dir, name)