Repository: bluesky/event-model (language: python)

Issue:
Convert event-model to use the dls python skeleton
As per a discussion with @coretl, we'd like to look into using the [dls skeleton](https://github.com/DiamondLightSource/python3-pip-skeleton/) with bluesky repos. NSLS are amenable to this, but we'll probably end up cherry picking desired  features.

We should first convert event-model.

## Expected Behavior
* All the features in the current dev environment should be functional.
* There are features we'll want to exclude from the pip skeleton e.g `.vscode`.


Discussion / hints:

Fix patch:
diff --git a/.flake8 b/.flake8
deleted file mode 100644
index a98b64cdb..000000000
--- a/.flake8
+++ /dev/null
@@ -1,10 +0,0 @@
-[flake8]
-exclude =
-    .git,
-    __pycache__,
-    build,
-    dist,
-    versioneer.py,
-    event_model/_version.py,
-    docs/source/conf.py
-max-line-length = 115
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 000000000..0bfeae912
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,3 @@
+# .git-blame-ignore-revs
+# Ran black and isort on event_model
+01898f228dc365ceb766785120c707a9897eee36
diff --git a/.github/CONTRIBUTING.rst b/.github/CONTRIBUTING.rst
new file mode 100644
index 000000000..3303ec01a
--- /dev/null
+++ b/.github/CONTRIBUTING.rst
@@ -0,0 +1,35 @@
+Contributing to the project
+===========================
+
+Contributions and issues are most welcome! All issues and pull requests are
+handled through GitHub_. Also, please check for any existing issues before
+filing a new one. If you have a great idea but it involves big changes, please
+file a ticket before making a pull request! We want to make sure you don't spend
+your time coding something that might not fit the scope of the project.
+
+.. _GitHub: https://github.com/bluesky/event-model/issues
+
+Issue or Discussion?
+--------------------
+
+Github also offers discussions_ as a place to ask questions and share ideas. If
+your issue is open ended and it is not obvious when it can be "closed", please
+raise it as a discussion instead.
+
+.. _discussions: https://github.com/bluesky/event-model/discussions
+
+Code coverage
+-------------
+
+While 100% code coverage does not make a library bug-free, it significantly
+reduces the number of easily caught bugs! Please make sure coverage remains the
+same or is improved by a pull request!
+
+Developer guide
+---------------
+
+The `Developer Guide`_ contains information on setting up a development
+environment, running the tests and what standards the code and documentation
+should follow.
+
+.. _Developer Guide: https://blueskyproject.io/event-model/main/developer/how-to/contribute.html
diff --git a/.github/actions/install_requirements/action.yml b/.github/actions/install_requirements/action.yml
new file mode 100644
index 000000000..51f2dfa3f
--- /dev/null
+++ b/.github/actions/install_requirements/action.yml
@@ -0,0 +1,62 @@
+name: Install requirements
+description: Run pip install with requirements and upload resulting requirements
+inputs:
+  requirements_file:
+    description: Name of requirements file to use and upload
+    required: true
+  install_options:
+    description: Parameters to pass to pip install
+    required: true
+  python_version:
+    description: Python version to install
+    default: "3.x"
+  jsonschema_version:
+    description: version of the jsonschema pip package to install
+    default: 3
+
+runs:
+  using: composite
+
+  steps:
+    - name: Setup python
+      uses: actions/setup-python@v4
+      with:
+        python-version: ${{ inputs.python_version }}
+
+    - name: Pip install
+      run: |
+        touch ${{ inputs.requirements_file }}
+        # -c uses requirements.txt as constraints, see 'Validate requirements file'
+        pip install -c ${{ inputs.requirements_file }} ${{ inputs.install_options }}
+        pip install --upgrade "jsonschema==${{inputs.jsonschema_version}}.*"
+      shell: bash
+
+    - name: Create lockfile
+      run: |
+        mkdir -p lockfiles
+        pip freeze --exclude-editable > lockfiles/${{ inputs.requirements_file }}
+        # delete the self referencing line and make sure it isn't blank
+        sed -i '/file:/d' lockfiles/${{ inputs.requirements_file }}
+      shell: bash
+
+    - name: Upload lockfiles
+      uses: actions/upload-artifact@v3
+      with:
+        name: lockfiles
+        path: lockfiles
+
+    # This eliminates the class of problems where the requirements being given no
+    # longer match what the packages themselves dictate. E.g. In the rare instance
+    # where I install some-package which used to depend on vulnerable-dependency
+    # but now uses good-dependency (despite being nominally the same version)
+    # pip will install both if given a requirements file with -r
+    - name: If requirements file exists, check it matches pip installed packages
+      run: |
+        if [ -s ${{ inputs.requirements_file }} ]; then
+          if ! diff -u ${{ inputs.requirements_file }} lockfiles/${{ inputs.requirements_file }}; then
+            echo "Error: ${{ inputs.requirements_file }} need the above changes to be exhaustive"
+            exit 1
+          fi
+        fi
+      shell: bash
+
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..fb7c6ee67
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,16 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+
+  - package-ecosystem: "pip"
+    directory: "/"
+    schedule:
+      interval: "weekly"
diff --git a/.github/pages/index.html b/.github/pages/index.html
new file mode 100644
index 000000000..80f0a0091
--- /dev/null
+++ b/.github/pages/index.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <title>Redirecting to main branch</title>
+    <meta charset="utf-8">
+    <meta http-equiv="refresh" content="0; url=./main/index.html">
+    <link rel="canonical" href="main/index.html">
+</head>
+
+</html>
\ No newline at end of file
diff --git a/.github/pages/make_switcher.py b/.github/pages/make_switcher.py
new file mode 100755
index 000000000..39c127726
--- /dev/null
+++ b/.github/pages/make_switcher.py
@@ -0,0 +1,99 @@
+import json
+import logging
+from argparse import ArgumentParser
+from pathlib import Path
+from subprocess import CalledProcessError, check_output
+from typing import List, Optional
+
+
+def report_output(stdout: bytes, label: str) -> List[str]:
+    ret = stdout.decode().strip().split("\n")
+    print(f"{label}: {ret}")
+    return ret
+
+
+def get_branch_contents(ref: str) -> List[str]:
+    """Get the list of directories in a branch."""
+    stdout = check_output(["git", "ls-tree", "-d", "--name-only", ref])
+    return report_output(stdout, "Branch contents")
+
+
+def get_sorted_tags_list() -> List[str]:
+    """Get a list of sorted tags in descending order from the repository."""
+    stdout = check_output(["git", "tag", "-l", "--sort=-v:refname"])
+    return report_output(stdout, "Tags list")
+
+
+def get_versions(ref: str, add: Optional[str], remove: Optional[str]) -> List[str]:
+    """Generate the file containing the list of all GitHub Pages builds."""
+    # Get the directories (i.e. builds) from the GitHub Pages branch
+    try:
+        builds = set(get_branch_contents(ref))
+    except CalledProcessError:
+        builds = set()
+        logging.warning(f"Cannot get {ref} contents")
+
+    # Add and remove from the list of builds
+    if add:
+        builds.add(add)
+    if remove:
+        assert remove in builds, f"Build '{remove}' not in {sorted(builds)}"
+        builds.remove(remove)
+
+    # Get a sorted list of tags
+    tags = get_sorted_tags_list()
+
+    # Make the sorted versions list from main branches and tags
+    versions: List[str] = []
+    for version in ["master", "main"] + tags:
+        if version in builds:
+            versions.append(version)
+            builds.remove(version)
+
+    # Add in anything that is left to the bottom
+    versions += sorted(builds)
+    print(f"Sorted versions: {versions}")
+    return versions
+
+
+def write_json(path: Path, repository: str, versions: str):
+    org, repo_name = repository.split("/")
+    struct = [
+        dict(version=version, url=f"https://{org}.github.io/{repo_name}/{version}/")
+        for version in versions
+    ]
+    text = json.dumps(struct, indent=2)
+    print(f"JSON switcher:\n{text}")
+    path.write_text(text)
+
+
+def main(args=None):
+    parser = ArgumentParser(
+        description="Make a versions.txt file from gh-pages directories"
+    )
+    parser.add_argument(
+        "--add",
+        help="Add this directory to the list of existing directories",
+    )
+    parser.add_argument(
+        "--remove",
+        help="Remove this directory from the list of existing directories",
+    )
+    parser.add_argument(
+        "repository",
+        help="The GitHub org and repository name: ORG/REPO",
+    )
+    parser.add_argument(
+        "output",
+        type=Path,
+        help="Path of write switcher.json to",
+    )
+    args = parser.parse_args(args)
+
+    # Write the versions file
+    versions = get_versions("origin/gh-pages", args.add, args.remove)
+    write_json(args.output, args.repository, versions)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml
new file mode 100644
index 000000000..4414029d2
--- /dev/null
+++ b/.github/workflows/code.yml
@@ -0,0 +1,146 @@
+name: Code CI
+
+on:
+  push:
+  pull_request:
+env:
+  # The target python version, which must match the Dockerfile version
+  CONTAINER_PYTHON: "3.11"
+
+jobs:
+  lint:
+    # pull requests are a duplicate of a branch push if within the same repo.
+    if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+
+      - name: Install python packages
+        uses: ./.github/actions/install_requirements
+        with:
+          requirements_file: requirements-dev-3.x.txt
+          install_options: -e .[dev]
+
+      - name: Lint
+        run: tox -e pre-commit #, mypy
+
+  test:
+    if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
+    strategy:
+      fail-fast: false
+      matrix:
+        os: ["ubuntu-latest"] # can add windows-latest, macos-latest
+        python: ["3.9", "3.10", "3.11"]
+        jsonschema: [2, 3]
+
+        install: ["-e .[dev]"]
+        # Make one version be non-editable to test both paths of version code
+        include:
+          - os: "ubuntu-latest"
+            python: "3.8"
+            install: ".[dev]"
+            jsonschema: 3
+
+    runs-on: ${{ matrix.os }}
+    env:
+      # https://github.com/pytest-dev/pytest/issues/2042
+      PY_IGNORE_IMPORTMISMATCH: "1"
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+        with:
+          # Need this to get version number from last tag
+          fetch-depth: 0
+
+      - name: Install python packages
+        uses: ./.github/actions/install_requirements
+        with:
+          python_version: ${{ matrix.python }}
+          jsonschema_version: ${{ matrix.jsonschema }}
+          requirements_file: requirements-test-${{ matrix.os }}-${{matrix.python }}-${{ matrix.jsonschema }}.txt
+          install_options: ${{ matrix.install }}
+
+      - name: List dependency tree
+        run: pipdeptree
+
+      - name: Run tests
+        run: pytest
+
+      - name: Upload coverage to Codecov
+        uses: codecov/codecov-action@v3
+        with:
+          name: ${{ matrix.python }}/${{ matrix.os }}
+          files: cov.xml
+
+  dist:
+    if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
+    runs-on: "ubuntu-latest"
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+        with:
+          # Need this to get version number from last tag
+          fetch-depth: 0
+
+      - name: Build sdist and wheel
+        run: |
+          export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) && \
+          pipx run build
+
+      - name: Upload sdist and wheel as artifacts
+        uses: actions/upload-artifact@v3
+        with:
+          name: dist
+          path: dist
+
+      - name: Check for packaging errors
+        run: pipx run twine check --strict dist/*
+
+      - name: Install python packages
+        uses: ./.github/actions/install_requirements
+        with:
+          python_version: ${{ env.CONTAINER_PYTHON }}
+          requirements_file: requirements.txt
+          install_options: dist/*.whl
+
+      - name: Test module --version works using the installed wheel
+        run: python -m event_model --version
+
+
+  release:
+    # upload to PyPI and make a release on every tag
+    needs: [lint, dist, test]
+    if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags') }}
+    runs-on: ubuntu-latest
+    env:
+      HAS_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN != '' }}
+
+    steps:
+      - uses: actions/download-artifact@v3
+
+      - name: Fixup blank lockfiles
+        # Github release artifacts can't be blank
+        run: for f in lockfiles/*; do [ -s $f ] || echo '# No requirements' >> $f; done
+
+      - name: Github Release
+        # We pin to the SHA, not the tag, for security reasons.
+        # https://docs.github.com/en/actions/learn-github-actions/security-hardening-for-github-actions#using-third-party-actions
+        uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v0.1.15
+        with:
+          prerelease: ${{ contains(github.ref_name, 'a') || contains(github.ref_name, 'b') || contains(github.ref_name, 'rc') }}
+          files: |
+            dist/*
+            lockfiles/*
+          generate_release_notes: true
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Publish to PyPI
+        if: ${{ env.HAS_PYPI_TOKEN }}
+        uses: pypa/gh-action-pypi-publish@release/v1
+        with:
+          password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 4249a85c3..1760e4e2e 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -1,36 +1,67 @@
-name: Documentation
+name: Docs CI
 
-on: [push, pull_request]
+on:
+  push:
+  pull_request:
 
 jobs:
-  build:
-
+  docs:
+    if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
     runs-on: ubuntu-latest
-    strategy:
-      matrix:
-        python-version: [3.8]
-      fail-fast: false
 
     steps:
+      - name: Avoid git conflicts when tag and branch pushed at same time
+        if: startsWith(github.ref, 'refs/tags')
+        run: sleep 60
+
+      - name: Checkout
+        uses: actions/checkout@v3
+        with:
+          # Need this to get version number from last tag
+          fetch-depth: 0
+
+      - name: Install system packages
+        # Can delete this if you don't use graphviz in your docs
+        run: sudo apt-get install graphviz
+
+      - name: Install python packages
+        uses: ./.github/actions/install_requirements
+        with:
+          requirements_file: requirements-dev-3.x.txt
+          install_options: -e .[dev]
 
-    - uses: actions/checkout@v2
+      - name: Build docs
+        run: tox -e docs
 
-    - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v2
-      with:
-        python-version: ${{ matrix.python-version }}
+      - name: Sanitize ref name for docs version
+        run: echo "DOCS_VERSION=${GITHUB_REF_NAME//[^A-Za-z0-9._-]/_}" >> $GITHUB_ENV
 
-    - name: Install
-      shell: bash -l {0}
-      run: source continuous_integration/scripts/install.sh
+      - name: Move to versioned directory
+        run: mv build/html .github/pages/$DOCS_VERSION
 
-    - name: Install documentation-building requirements
-      shell: bash -l {0}
-      run: |
-        set -vxeuo pipefail
-        python -m pip install -r requirements-dev.txt
-        python -m pip list
+      - name: Write switcher.json
+        run: python .github/pages/make_switcher.py --add $DOCS_VERSION ${{ github.repository }} .github/pages/switcher.json
 
-    - name: Build Docs
-      shell: bash -l {0}
-      run: make -C docs/ html
+      - name: Deploy documentation to blueskyproject.io.
+        if: github.event_name == 'push' && github.actor != 'dependabot[bot]'
+        # We pin to the SHA, not the tag, for security reasons.
+        # https://docs.github.com/en/actions/learn-github-actions/security-hardening-for-github-actions#using-third-party-actions
+        uses: peaceiris/actions-gh-pages@bbdfb200618d235585ad98e965f4aafc39b4c501  # v3.7.3
+        with:
+          deploy_key: ${{ secrets.ACTIONS_DOCUMENTATION_DEPLOY_KEY }}
+          publish_branch: master
+          publish_dir: ./docs/build/html
+          external_repository: bluesky/blueskyproject.io
+          destination_dir: ${{ env.REPOSITORY_NAME }}  # just the repo name, without the "bluesky/"
+          keep_files: true
+          force_orphan: false  # Keep git history.
+        
+      - name: Publish Docs to gh-pages
+        if: github.event_name == 'push' && github.actor != 'dependabot[bot]'
+        # We pin to the SHA, not the tag, for security reasons.
+        # https://docs.github.com/en/actions/learn-github-actions/security-hardening-for-github-actions#using-third-party-actions
+        uses: peaceiris/actions-gh-pages@64b46b4226a4a12da2239ba3ea5aa73e3163c75b # v3.9.1
+        with:
+          github_token: ${{ secrets.GITHUB_TOKEN }}
+          publish_dir: .github/pages
+          keep_files: true
\ No newline at end of file
diff --git a/.github/workflows/docs_clean.yml b/.github/workflows/docs_clean.yml
new file mode 100644
index 000000000..a67e18815
--- /dev/null
+++ b/.github/workflows/docs_clean.yml
@@ -0,0 +1,43 @@
+name: Docs Cleanup CI
+
+# delete branch documentation when a branch is deleted
+# also allow manually deleting a documentation version
+on:
+  delete:
+  workflow_dispatch:
+    inputs:
+      version:
+        description: "documentation version to DELETE"
+        required: true
+        type: string
+
+jobs:
+  remove:
+    if: github.event.ref_type == 'branch' || github.event_name == 'workflow_dispatch'
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+        with:
+          ref: gh-pages
+
+      - name: removing documentation for branch ${{ github.event.ref }}
+        if: ${{ github.event_name != 'workflow_dispatch' }}
+        run: echo "REF_NAME=${{ github.event.ref }}" >> $GITHUB_ENV
+
+      - name: manually removing documentation version ${{ github.event.inputs.version }}
+        if: ${{ github.event_name == 'workflow_dispatch' }}
+        run: echo "REF_NAME=${{ github.event.inputs.version }}" >> $GITHUB_ENV
+
+      - name: Sanitize ref name for docs version
+        run: echo "DOCS_VERSION=${REF_NAME//[^A-Za-z0-9._-]/_}" >> $GITHUB_ENV
+
+      - name: update index and push changes
+        run: |
+          rm -r $DOCS_VERSION
+          python make_switcher.py --remove $DOCS_VERSION ${{ github.repository }} switcher.json
+          git config --global user.name 'GitHub Actions Docs Cleanup CI'
+          git config --global user.email 'GithubActionsCleanup@noreply.github.com'
+          git commit -am "Removing redundant docs version $DOCS_VERSION"
+          git push
diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml
deleted file mode 100644
index 25dabb31b..000000000
--- a/.github/workflows/flake8.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-name: Check Code Style
-
-on: [push, pull_request]
-
-jobs:
-  lint:
-    runs-on: ubuntu-latest
-    strategy:
-      fail-fast: false
-    steps:
-
-      - uses: actions/checkout@v2
-
-      - uses: actions/setup-python@v2
-
-      - name: Install flake8
-        shell: bash -l {0}
-        run: |
-          set -vxeuo pipefail
-          python -m pip install --upgrade pip
-          python -m pip install flake8
-          python -m pip list
-
-      - name: Run flake8
-        shell: bash -l {0}
-        run: flake8
diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml
new file mode 100644
index 000000000..1eaa1e9b7
--- /dev/null
+++ b/.github/workflows/linkcheck.yml
@@ -0,0 +1,31 @@
+name: Link Check
+
+on:
+  schedule:
+    # Run every Wednesday at 8am to check URL links still resolve
+    - cron: "0 8 * * WED"
+
+jobs:
+  docs:
+    strategy:
+      fail-fast: false
+
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+
+      - name: Install python packages
+        uses: ./.github/actions/install_requirements
+        with:
+          requirements_file: requirements-dev-3.x.txt
+          install_options: -e .[dev]
+
+      - name: Check links
+        run: tox -e docs -- -b linkcheck
+
+      - name: Keepalive Workflow
+        uses: gautamkrishnar/keepalive-workflow@v1
diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml
deleted file mode 100644
index 8a5153e2a..000000000
--- a/.github/workflows/publish-docs.yml
+++ /dev/null
@@ -1,60 +0,0 @@
-name: Publish Documentation
-
-on:
-  push:
-    branches:
-      - master
-
-jobs:
-  build:
-    if: github.repository_owner == 'bluesky'
-    runs-on: ubuntu-latest
-    strategy:
-      matrix:
-        python-version: [3.8]
-      fail-fast: false
-
-    steps:
-
-    - name: Set env.REPOSITORY_NAME  # just the repo, as opposed to org/repo
-      shell: bash -l {0}
-      run: |
-        export REPOSITORY_NAME=${GITHUB_REPOSITORY#*/}
-        echo "REPOSITORY_NAME=${REPOSITORY_NAME}" >> $GITHUB_ENV
-
-    - uses: actions/checkout@v2
-      with:
-        fetch-depth: 1000  # should be enough to reach the most recent tag
-
-    - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v2
-      with:
-        python-version: ${{ matrix.python-version }}
-
-    - name: Install
-      shell: bash -l {0}
-      run: source continuous_integration/scripts/install.sh
-
-    - name: Install documentation-building requirements
-      shell: bash -l {0}
-      run: |
-        set -vxeuo pipefail
-        python -m pip install -r requirements-dev.txt
-        python -m pip list
-
-    - name: Build Docs
-      shell: bash -l {0}
-      run: make -C docs/ html
-
-    - name: Deploy documentation to blueskyproject.io.
-      # We pin to the SHA, not the tag, for security reasons.
-      # https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/security-hardening-for-github-actions#using-third-party-actions
-      uses: peaceiris/actions-gh-pages@bbdfb200618d235585ad98e965f4aafc39b4c501  # v3.7.3
-      with:
-        deploy_key: ${{ secrets.ACTIONS_DOCUMENTATION_DEPLOY_KEY }}
-        publish_branch: master
-        publish_dir: ./docs/build/html
-        external_repository: bluesky/bluesky.github.io
-        destination_dir: ${{ env.REPOSITORY_NAME }}  # just the repo name, without the "bluesky/"
-        keep_files: true  # Keep old files.
-        force_orphan: false  # Keep git history.
diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml
deleted file mode 100644
index 144f780ed..000000000
--- a/.github/workflows/publish-pypi.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-# This workflow will upload a Python Package using flit when a release is
-# created.  For more information see:
-# https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
-
-name: Upload Python Package
-
-on:
-  release:
-    types: [created]
-
-jobs:
-  deploy:
-
-    runs-on: ubuntu-latest
-
-    steps:
-      - uses: actions/checkout@v2
-      - name: Set up Python
-        uses: actions/setup-python@v2
-        with:
-          python-version: '3.x'
-      - name: Install dependencies
-        run: |
-          python -m pip install --upgrade pip
-          pip install wheel twine setuptools
-      - name: Build and publish
-        env:
-          TWINE_USERNAME: __token__
-          # The PYPI_PASSWORD must be a pypi token with the "pypi-" prefix with sufficient permissions to upload this package
-          # https://pypi.org/help/#apitoken
-          TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
-        run: |
-          python setup.py sdist bdist_wheel
-          twine upload dist/*
diff --git a/.gitignore b/.gitignore
index 7418a9a9a..eb2221177 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,6 @@
 # Byte-compiled / optimized / DLL files
 __pycache__/
 *.py[cod]
-*$py.class
 
 # C extensions
 *.so
@@ -9,6 +8,7 @@ __pycache__/
 # Distribution / packaging
 .Python
 env/
+.venv
 build/
 develop-eggs/
 dist/
@@ -20,10 +20,13 @@ lib64/
 parts/
 sdist/
 var/
-venv/
 *.egg-info/
 .installed.cfg
 *.egg
+*.egg-info/
+.installed.cfg
+*.egg
+**/_version.py
 
 # PyInstaller
 #  Usually these files are written by a python script from a template
@@ -43,8 +46,9 @@ htmlcov/
 .cache
 nosetests.xml
 coverage.xml
-*,cover
-.hypothesis/
+cov.xml
+.pytest_cache/
+.mypy_cache/
 
 # Translations
 *.mo
@@ -54,15 +58,18 @@ coverage.xml
 *.log
 
 # Sphinx documentation
-docs/build/
-docs/source/generated/
-
-# pytest
-.pytest_cache/
+docs/_build/
 
 # PyBuilder
 target/
 
+# likely venv names
+.venv*
+venv*
+
+# further build artifacts
+lockfiles/
+
 # Editor files
 #mac
 .DS_Store
@@ -76,10 +83,4 @@ target/
 .idea/*
 
 #vscode
-.vscode/*
-
-#Ipython Notebook
-.ipynb_checkpoints
-
-# precomit
-.pre-commit-config.yaml
\ No newline at end of file
+.vscode/*
\ No newline at end of file
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 000000000..aa2a4cb2c
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,23 @@
+repos:
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v4.4.0
+    hooks:
+      - id: check-added-large-files
+      - id: check-yaml
+      - id: check-merge-conflict
+
+  - repo: local
+    hooks:
+      - id: black
+        name: Run black
+        stages: [commit]
+        language: system
+        entry: black --check --diff
+        types: [python]
+
+      - id: flake8
+        name: Run flake8
+        stages: [commit]
+        language: system
+        entry: flake8
+        types: [python]
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index 670a7a73c..000000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,17 +0,0 @@
-include AUTHORS.rst
-include CONTRIBUTING.rst
-include LICENSE
-include README.rst
-include requirements.txt
-
-recursive-exclude * __pycache__
-recursive-exclude * *.py[co]
-
-recursive-include docs *.rst conf.py Makefile make.bat
-
-include versioneer.py
-include event_model/_version.py
-include event_model/schemas/*.json
-
-# If including data files in the package, add them like:
-# include path/to/data_file
diff --git a/README.md b/README.md
deleted file mode 100644
index c9216e4ce..000000000
--- a/README.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Event Model
-
-[![Build Status](https://img.shields.io/github/workflow/status/bluesky/event-model/Unit%20Tests)](https://github.com/bluesky/event-model/actions?query=workflow%3A%22Unit+Tests%22+branch%3Amaster)
-
-[**Documentation**](https://blueskyproject.io/event-model/)
diff --git a/README.rst b/README.rst
new file mode 100644
index 000000000..91cfa9f8f
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,37 @@
+Event Model
+===========================
+
+|code_ci| |docs_ci| |coverage| |pypi_version| |license|
+
+============== ==============================================================
+PyPI           ``pip install event-model``
+Source code    https://github.com/bluesky/event-model
+Documentation  https://blueskyproject.io/event-model
+Releases       https://github.com/bluesky/event-model/releases
+============== ==============================================================
+
+.. |code_ci| image:: https://github.com/bluesky/event-model/actions/workflows/code.yml/badge.svg?branch=master
+    :target: https://github.com/bluesky/event-model/actions/workflows/code.yml
+    :alt: Code CI
+
+.. |docs_ci| image:: https://github.com/bluesky/event-model/actions/workflows/docs.yml/badge.svg?branch=master
+    :target: https://github.com/bluesky/event-model/actions/workflows/docs.yml
+    :alt: Docs CI
+
+.. |coverage| image:: https://codecov.io/gh/bluesky/event-model/branch/master/graph/badge.svg
+    :target: https://codecov.io/gh/bluesky/event-model
+    :alt: Test Coverage
+
+.. |pypi_version| image:: https://img.shields.io/pypi/v/event-model.svg
+    :target: https://pypi.org/project/event-model
+    :alt: Latest PyPI version
+
+.. |license| image:: https://img.shields.io/badge/License-BSD-blue.svg
+    :target: https://opensource.org/license/bsd-3-clause/
+    :alt: BSD License
+
+..
+    Anything below this line is used when viewing README.rst and will be replaced
+    when included in index.rst
+
+See https://blueskyproject.io/event-model for more detailed documentation.
diff --git a/continuous_integration/scripts/install.sh b/continuous_integration/scripts/install.sh
deleted file mode 100644
index 04479c0a0..000000000
--- a/continuous_integration/scripts/install.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/bin/bash
-set -vxeuo pipefail
-
-# These packages are installed in the base environment but may be older
-# versions. Explicitly upgrade them because they often create
-# installation problems if out of date.
-python -m pip install --upgrade pip setuptools wheel numpy
-# Versioneer uses the most recent git tag to generate __version__, which appears
-# in the published documentation.
-git fetch --tags
-python -m pip install .
-python -m pip list
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 000000000..6a0f313c1
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,280 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# This file only contains a selection of the most common options. For a full
+# list see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+import sys
+from pathlib import Path
+from subprocess import check_output
+
+import requests
+
+import event_model
+
+# -- General configuration ------------------------------------------------
+
+# General information about the project.
+project = "Bluesky Event Model"
+copyright = "2019, Brookhaven National Lab"
+author = "Brookhaven National Lab"
+
+
+# The full version, including alpha/beta/rc tags.
+release = event_model.__version__
+
+# The short X.Y version.
+if "+" in release:
+    # Not on a tag, use branch name
+    root = Path(__file__).absolute().parent.parent
+    git_branch = check_output("git branch --show-current".split(), cwd=root)
+    version = git_branch.decode().strip()
+else:
+    version = release
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = "en"
+
+extensions = [
+    # Use this for generating API docs
+    "sphinx.ext.autodoc",
+    # This can parse google style docstrings
+    "sphinx.ext.napoleon",
+    # For linking to external sphinx documentation
+    "sphinx.ext.intersphinx",
+    # Add links to source code in API docs
+    "sphinx.ext.viewcode",
+    # Adds the inheritance-diagram generation directive
+    "sphinx.ext.inheritance_diagram",
+    # Add a copy button to each code block
+    "sphinx_copybutton",
+    # For the card element
+    "sphinx_design",
+    "sphinx.ext.autosummary",
+    "sphinx.ext.mathjax",
+    "sphinx.ext.githubpages",
+    "matplotlib.sphinxext.plot_directive",
+    "sphinx_copybutton",
+    "IPython.sphinxext.ipython_directive",
+    "IPython.sphinxext.ipython_console_highlighting",
+]
+
+napoleon_google_docstring = False
+napoleon_numpy_docstring = True
+
+# If true, Sphinx will warn about all references where the target cannot
+# be found.
+nitpicky = False
+
+# A list of (type, target) tuples (by default empty) that should be ignored when
+# generating warnings in "nitpicky mode". Note that type should include the
+# domain name if present. Example entries would be ('py:func', 'int') or
+# ('envvar', 'LD_LIBRARY_PATH').
+nitpick_ignore = [
+    ("py:class", "NoneType"),
+    ("py:class", "'str'"),
+    ("py:class", "'float'"),
+    ("py:class", "'int'"),
+    ("py:class", "'bool'"),
+    ("py:class", "'object'"),
+    ("py:class", "'id'"),
+    ("py:class", "typing_extensions.Literal"),
+]
+
+# Both the class’ and the __init__ method’s docstring are concatenated and
+# inserted into the main body of the autoclass directive
+autoclass_content = "both"
+
+# Order the members by the order they appear in the source code
+autodoc_member_order = "bysource"
+
+# Don't inherit docstrings from baseclasses
+autodoc_inherit_docstrings = False
+
+# Output graphviz directive produced images in a scalable format
+graphviz_output_format = "svg"
+
+# The name of a reST role (builtin or Sphinx extension) to use as the default
+# role, that is, for text marked up `like this`
+default_role = "any"
+
+# The suffix of source filenames.
+source_suffix = ".rst"
+
+# The master toctree document.
+master_doc = "index"
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# These patterns also affect html_static_path and html_extra_path
+exclude_patterns = ["_build"]
+
+# If true, `todo` and `todoList` produce output, else they produce nothing.
+todo_include_todos = False
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = "sphinx"
+
+# Example configuration for intersphinx: refer to the Python standard library.
+# This means you can link things like `str` and `asyncio` to the relevant
+# docs in the python documentation.
+intersphinx_mapping = {
+    "python": ("https://docs.python.org/3/", None),
+    "cachetools": ("https://cachetools.readthedocs.io/en/stable/", None),
+    "numpy": ("https://docs.scipy.org/doc/numpy/", None),
+    "scipy": ("https://docs.scipy.org/doc/scipy/reference/", None),
+    "pandas": ("https://pandas.pydata.org/pandas-docs/stable", None),
+    "matplotlib": ("https://matplotlib.org", None),
+    "jsonschema": ("https://python-jsonschema.readthedocs.io/en/stable/", None),
+}
+
+# A dictionary of graphviz graph attributes for inheritance diagrams.
+inheritance_graph_attrs = dict(rankdir="TB")
+
+# Common links that should be available on every page
+rst_epilog = """
+.. _NSLS: https://www.bnl.gov/nsls2
+.. _black: https://github.com/psf/black
+.. _flake8: https://flake8.pycqa.org/en/latest/
+.. _isort: https://github.com/PyCQA/isort
+.. _mypy: http://mypy-lang.org/
+.. _pre-commit: https://pre-commit.com/
+"""
+
+# Ignore localhost links for periodic check that links in docs are valid
+linkcheck_ignore = [r"http://localhost:\d+/"]
+
+# Set copy-button to ignore python and bash prompts
+# https://sphinx-copybutton.readthedocs.io/en/latest/use.html#using-regexp-prompt-identifiers
+copybutton_prompt_text = r">>> |\.\.\. |\$ |In \[\d*\]: | {2,5}\.\.\.: | {5,8}: "
+copybutton_prompt_is_regexp = True
+
+# -- Options for manual page output ---------------------------------------
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title,
+#  author, documentclass [howto, manual, or own class]).
+latex_documents = [
+    (
+        master_doc,
+        "event-model.tex",
+        "Bluesky Event Model Documentation",
+        "Contributors",
+        "manual",
+    ),
+]
+
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+    (master_doc, "event-model", "Bluesky Event Model Documentation", [author], 1)
+]
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+#  dir menu entry, description, category)
+texinfo_documents = [
+    (
+        master_doc,
+        "event-model",
+        "Bluesky Event Model Documentation",
+        author,
+        "event-model",
+        "Data model used by the bluesky ecosystem",
+        "Miscellaneous",
+    ),
+]
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#
+html_theme = "pydata_sphinx_theme"
+github_repo = project
+github_user = "bluesky"
+switcher_json = f"https://{github_user}.github.io/{github_repo}/switcher.json"
+switcher_exists = requests.get(switcher_json).ok
+if not switcher_exists:
+    print(
+        "*** Can't read version switcher, is GitHub pages enabled? \n"
+        "    Once Docs CI job has successfully run once, set the "
+        "Github pages source branch to be 'gh-pages' at:\n"
+        f"    https://github.com/{github_user}/{github_repo}/settings/pages",
+        file=sys.stderr,
+    )
+
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = "event-model"
+
+# Custom sidebar templates, must be a dictionary that maps document names
+# to template names.
+#
+# This is required for the alabaster theme
+# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
+html_sidebars = {
+    "**": [
+        "relations.html",  # needs 'show_related': True theme option to display
+        "searchbox.html",
+    ]
+}
+
+# Theme options for pydata_sphinx_theme
+# We don't check switcher because there are 3 possible states for a repo:
+# 1. New project, docs are not published so there is no switcher
+# 2. Existing project with latest skeleton, switcher exists and works
+# 3. Existing project with old skeleton that makes broken switcher,
+#    switcher exists but is broken
+# Point 3 makes checking switcher difficult, because the updated skeleton
+# will fix the switcher at the end of the docs workflow, but never gets a chance
+# to complete as the docs build warns and fails.
+html_theme_options = dict(
+    logo=dict(
+        text=project,
+    ),
+    use_edit_page_button=True,
+    github_url=f"https://github.com/{github_user}/{github_repo}",
+    icon_links=[
+        dict(
+            name="PyPI",
+            url=f"https://pypi.org/project/{project}",
+            icon="fas fa-cube",
+        )
+    ],
+    switcher=dict(
+        json_url=switcher_json,
+        version_match=version,
+    ),
+    check_switcher=False,
+    navbar_end=["theme-switcher", "icon-links", "version-switcher"],
+    external_links=[
+        dict(
+            name="Release Notes",
+            url=f"https://github.com/{github_user}/{github_repo}/releases",
+        )
+    ],
+)
+
+# A dictionary of values to pass into the template engine’s context for all pages
+html_context = dict(
+    github_user=github_user,
+    github_repo=project,
+    github_version=version,
+    doc_path="docs",
+)
+
+# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
+html_show_sphinx = False
+
+# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
+html_show_copyright = False
+
+# Logo
+html_logo = "images/bluesky-logo-dark.svg"
+# html_favicon = "images/dls-favicon.ico"
diff --git a/docs/developer/explanations/decisions.rst b/docs/developer/explanations/decisions.rst
new file mode 100644
index 000000000..5841e6ea0
--- /dev/null
+++ b/docs/developer/explanations/decisions.rst
@@ -0,0 +1,17 @@
+.. This Source Code Form is subject to the terms of the Mozilla Public
+.. License, v. 2.0. If a copy of the MPL was not distributed with this
+.. file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Architectural Decision Records
+==============================
+
+We record major architectural decisions in Architecture Decision Records (ADRs),
+as `described by Michael Nygard
+<http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions>`_.
+Below is the list of our current ADRs.
+
+.. toctree::
+    :maxdepth: 1
+    :glob:
+
+    decisions/*
\ No newline at end of file
diff --git a/docs/developer/explanations/decisions/0001-record-architecture-decisions.rst b/docs/developer/explanations/decisions/0001-record-architecture-decisions.rst
new file mode 100644
index 000000000..96217d58f
--- /dev/null
+++ b/docs/developer/explanations/decisions/0001-record-architecture-decisions.rst
@@ -0,0 +1,26 @@
+1. Record architecture decisions
+================================
+
+Date: 2023-02-18
+
+Status
+------
+
+Accepted
+
+Context
+-------
+
+We need to record the architectural decisions made on this project.
+
+Decision
+--------
+
+We will use Architecture Decision Records, as `described by Michael Nygard
+<http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions>`_.
+
+Consequences
+------------
+
+See Michael Nygard's article, linked above. To create new ADRs we will copy and
+paste from existing ones.
diff --git a/docs/developer/explanations/decisions/0002-switched-to-using-a-python-skeleton.rst b/docs/developer/explanations/decisions/0002-switched-to-using-a-python-skeleton.rst
new file mode 100644
index 000000000..996eb13f1
--- /dev/null
+++ b/docs/developer/explanations/decisions/0002-switched-to-using-a-python-skeleton.rst
@@ -0,0 +1,32 @@
+2. Switched to using a python skeleton
+======================================
+
+Date: 2023-03-23
+
+Status
+------
+
+Accepted
+
+Context
+-------
+
+We should use a python skeleton with the following new development features:
+- using a single pyproject.toml instead of requirements.txt and requirements-dev.txt
+- black
+- isort
+- having the git version generated by setup.py instead of versioneer
+- mypy
+- pre-commit
+
+
+Decision
+--------
+
+We will use the
+`DLS python pip skeleton <https://github.com/DiamondLightSource/python3-pip-skeleton>`_.
+
+Consequences
+------------
+
+CI workflows will change, even jobs providing the same feature as the older checks.
\ No newline at end of file
diff --git a/docs/developer/how-to/build-docs.rst b/docs/developer/how-to/build-docs.rst
new file mode 100644
index 000000000..a2a55ca3e
--- /dev/null
+++ b/docs/developer/how-to/build-docs.rst
@@ -0,0 +1,38 @@
+Build the docs using sphinx
+===========================
+
+You can build the `sphinx`_ based docs from the project directory by running::
+
+    $ tox -e docs
+
+This will build the static docs on the ``docs`` directory, which includes API
+docs that pull in docstrings from the code.
+
+.. seealso::
+
+    `documentation_standards`
+
+The docs will be built into the ``build/html`` directory, and can be opened
+locally with a web browser::
+
+    $ firefox build/html/index.html
+
+Autobuild
+---------
+
+You can also run an autobuild process, which will watch your ``docs``
+directory for changes and rebuild whenever it sees changes, reloading any
+browsers watching the pages::
+
+    $ tox -e docs autobuild
+
+You can view the pages at localhost::
+
+    $ firefox http://localhost:8000
+
+If you are making changes to source code too, you can tell it to watch
+changes in this directory too::
+
+    $ tox -e docs autobuild -- --watch event_model/
+
+.. _sphinx: https://www.sphinx-doc.org/
\ No newline at end of file
diff --git a/docs/developer/how-to/contribute.rst b/docs/developer/how-to/contribute.rst
new file mode 100644
index 000000000..65b992f08
--- /dev/null
+++ b/docs/developer/how-to/contribute.rst
@@ -0,0 +1,1 @@
+.. include:: ../../../.github/CONTRIBUTING.rst
diff --git a/docs/developer/how-to/lint.rst b/docs/developer/how-to/lint.rst
new file mode 100644
index 000000000..099dec5f1
--- /dev/null
+++ b/docs/developer/how-to/lint.rst
@@ -0,0 +1,33 @@
+Run linting using pre-commit
+============================
+
+Code linting is handled by black_, flake8_ and isort_ run under pre-commit_.
+
+Running pre-commit
+------------------
+
+You can run the above checks on all files with this command::
+
+    $ tox -e pre-commit
+
+Or you can install a pre-commit hook that will run each time you do a ``git
+commit`` on just the files that have changed::
+
+    $ pre-commit install
+
+It is also possible to `automatically enable pre-commit on cloned repositories <https://pre-commit.com/#automatically-enabling-pre-commit-on-repositories>`_.
+This will result in pre-commits being enabled on every repo your user clones from now on.
+
+Fixing issues
+-------------
+
+If black reports an issue you can tell it to reformat all the files in the
+repository::
+
+    $ black .
+
+Likewise with isort::
+
+    $ isort .
+
+If you get any flake8 issues you will have to fix those manually.
\ No newline at end of file
diff --git a/docs/developer/how-to/make-release.rst b/docs/developer/how-to/make-release.rst
new file mode 100644
index 000000000..230025f09
--- /dev/null
+++ b/docs/developer/how-to/make-release.rst
@@ -0,0 +1,16 @@
+Make a release
+==============
+
+To make a new release, please follow this checklist:
+
+- Choose a new PEP440 compliant release number (see https://peps.python.org/pep-0440/)
+- Go to the GitHub release_ page
+- Choose ``Draft New Release``
+- Click ``Choose Tag`` and supply the new tag you chose (click create new tag)
+- Click ``Generate release notes``, review and edit these notes
+- Choose a title and click ``Publish Release``
+
+Note that tagging and pushing to the main branch has the same effect except that
+you will not get the option to edit the release notes.
+
+.. _release: https://github.com/bluesky/event-model/releases
diff --git a/docs/developer/how-to/pin-requirements.rst b/docs/developer/how-to/pin-requirements.rst
new file mode 100644
index 000000000..89639623a
--- /dev/null
+++ b/docs/developer/how-to/pin-requirements.rst
@@ -0,0 +1,74 @@
+Pinning Requirements
+====================
+
+Introduction
+------------
+
+By design this project only defines dependencies in one place, i.e. in
+the ``requires`` table in ``pyproject.toml``.
+
+In the ``requires`` table it is possible to pin versions of some dependencies
+as needed. For library projects it is best to leave pinning to a minimum so
+that your library can be used by the widest range of applications.
+
+When CI builds the project it will use the latest compatible set of
+dependencies available (after applying your pins and any dependencies' pins).
+
+This approach means that there is a possibility that a future build may
+break because an updated release of a dependency has made a breaking change.
+
+The correct way to fix such an issue is to work out the minimum pinning in
+``requires`` that will resolve the problem. However this can be quite hard to
+do and may be time consuming when simply trying to release a minor update.
+
+For this reason we provide a mechanism for locking all dependencies to
+the same version as a previous successful release. This is a quick fix that
+should guarantee a successful CI build.
+
+Finding the lock files
+----------------------
+
+Every release of the project will have a set of requirements files published
+as release assets.
+
+For example take a look at the release page for python3-pip-skeleton-cli here:
+https://github.com/DiamondLightSource/python3-pip-skeleton-cli/releases/tag/3.3.0
+
+There is a list of requirements*.txt files showing as assets on the release.
+
+There is one file for each time the CI installed the project into a virtual
+environment. There are multiple of these as the CI creates a number of
+different environments.
+
+The files are created using ``pip freeze`` and will contain a full list
+of the dependencies and sub-dependencies with pinned versions.
+
+You can download any of these files by clicking on them. It is best to use
+the one that ran with the lowest Python version as this is more likely to
+be compatible with all the versions of Python in the test matrix.
+i.e. ``requirements-test-ubuntu-latest-3.8.txt`` in this example.
+
+Applying the lock file
+----------------------
+
+To apply a lockfile:
+
+- copy the requirements file you have downloaded to the root of your
+  repository
+- rename it to requirements.txt
+- commit it into the repo
+- push the changes
+
+The CI looks for a requirements.txt in the root and will pass it to pip
+when installing each of the test environments. pip will then install exactly
+the same set of packages as the previous release.
+
+Removing dependency locking from CI
+-----------------------------------
+
+Once the reasons for locking the build have been resolved it is a good idea
+to go back to an unlocked build. This is because you get an early indication
+of any incoming problems.
+
+To restore unlocked builds in CI simply remove requirements.txt from the root
+of the repo and push.
diff --git a/docs/developer/how-to/static-analysis.rst b/docs/developer/how-to/static-analysis.rst
new file mode 100644
index 000000000..065920e1c
--- /dev/null
+++ b/docs/developer/how-to/static-analysis.rst
@@ -0,0 +1,8 @@
+Run static analysis using mypy
+==============================
+
+Static type analysis is done with mypy_. It checks type definition in source
+files without running them, and highlights potential issues where types do not
+match. You can run it with::
+
+    $ tox -e mypy
diff --git a/docs/developer/how-to/update-tools.rst b/docs/developer/how-to/update-tools.rst
new file mode 100644
index 000000000..7c78f94a6
--- /dev/null
+++ b/docs/developer/how-to/update-tools.rst
@@ -0,0 +1,16 @@
+Update the tools
+================
+
+This module is merged with the python3-pip-skeleton_. This is a generic
+Python project structure which provides a means to keep tools and
+techniques in sync between multiple Python projects. To update to the
+latest version of the skeleton, run::
+
+    $ git pull --rebase=false https://github.com/bluesky/python3-pip-skeleton
+
+Any merge conflicts will indicate an area where something has changed that
+conflicts with the setup of the current module. Check the `closed pull requests
+<https://github.com/bluesky/python3-pip-skeleton/pulls?q=is%3Apr+is%3Aclosed>`_
+of the skeleton module for more details.
+
+.. _python3-pip-skeleton: https://blueskyproject.io/python3-pip-skeleton
diff --git a/docs/developer/index.rst b/docs/developer/index.rst
new file mode 100644
index 000000000..30f9d4b2b
--- /dev/null
+++ b/docs/developer/index.rst
@@ -0,0 +1,64 @@
+Developer Guide
+===============
+
+Documentation is split into four categories, also accessible from links in the
+side-bar.
+
+.. grid:: 2
+    :gutter: 4
+
+    .. grid-item-card:: :material-regular:`directions_run;3em`
+
+        .. toctree::
+            :caption: Tutorials
+            :maxdepth: 1
+
+            tutorials/dev-install
+
+        +++
+
+        Tutorials for getting up and running as a developer.
+
+    .. grid-item-card:: :material-regular:`task;3em`
+
+        .. toctree::
+            :caption: How-to Guides
+            :maxdepth: 1
+
+            how-to/contribute
+            how-to/build-docs
+            how-to/run-tests
+            how-to/static-analysis
+            how-to/lint
+            how-to/update-tools
+            how-to/make-release
+            how-to/pin-requirements
+
+        +++
+
+        Practical step-by-step guides for day-to-day dev tasks.
+
+    .. grid-item-card:: :material-regular:`apartment;3em`
+
+        .. toctree::
+            :caption: Explanations
+            :maxdepth: 1
+
+            explanations/decisions
+
+        +++
+
+        Explanations of how and why the architecture is why it is.
+
+    .. grid-item-card:: :material-regular:`description;3em`
+
+        .. toctree::
+            :caption: Reference
+            :maxdepth: 1
+
+            reference/standards
+            reference/release-history
+
+        +++
+
+        Technical reference material on standards in use.
diff --git a/docs/source/release-history.rst b/docs/developer/reference/release-history.rst
similarity index 98%
rename from docs/source/release-history.rst
rename to docs/developer/reference/release-history.rst
index 0439d26f3..c4d562c58 100644
--- a/docs/source/release-history.rst
+++ b/docs/developer/reference/release-history.rst
@@ -23,8 +23,8 @@ v1.19.0 (2022-11-03)
 Added
 -----
 
-* Add two experimental new document types: a `stream_resource` that manages an
-  unknown number of contiguous `stream_datum`, with the potential for multiple
+* Add two experimental new document types: a :std:ref:`stream_resource` that manages an
+  unknown number of contiguous :std:ref:`stream_datum`, with the potential for multiple
   streams. This is especially relevant when the data is expected to be ragged
   or has no pre-determined shape (number of rows).
 
diff --git a/docs/developer/reference/standards.rst b/docs/developer/reference/standards.rst
new file mode 100644
index 000000000..01265b713
--- /dev/null
+++ b/docs/developer/reference/standards.rst
@@ -0,0 +1,144 @@
+Standards
+=========
+
+This document defines the code and documentation standards used in this
+repository.
+
+Code Standards
+--------------
+
+The code in this repository conforms to standards set by the following tools:
+
+- black_ for code formatting
+- flake8_ for style checks
+- isort_ for import ordering
+- mypy_ for static type checking
+
+.. seealso::
+
+    How-to guides `../how-to/lint` and `../how-to/static-analysis`
+
+.. _documentation_standards:
+
+Documentation Standards
+-----------------------
+
+Docstrings are pre-processed using the Sphinx Napoleon extension. As such,
+numpydoc-style_ is considered as standard for this repository. Please use type
+hints in the function signature for types. For example:
+
+.. code:: python
+
+    def foo(var1, var2, *args, long_var_name="hi", only_seldom_used_keyword=0, **kwargs):
+    r"""Summarize the function in one line.
+
+    Several sentences providing an extended description. Refer to
+    variables using back-ticks, e.g. `var`.
+
+    Parameters
+    ----------
+    var1 : array_like
+        Array_like means all those objects -- lists, nested lists, etc. --
+        that can be converted to an array.  We can also refer to
+        variables like `var1`.
+    var2 : int
+        The type above can either refer to an actual Python type
+        (e.g. ``int``), or describe the type of the variable in more
+        detail, e.g. ``(N,) ndarray`` or ``array_like``.
+    *args : iterable
+        Other arguments.
+    long_var_name : {'hi', 'ho'}, optional
+        Choices in brackets, default first when optional.
+
+    Returns
+    -------
+    type
+        Explanation of anonymous return value of type ``type``.
+    describe : type
+        Explanation of return value named `describe`.
+    out : type
+        Explanation of `out`.
+    type_without_description
+
+    Other Parameters
+    ----------------
+    only_seldom_used_keyword : int, optional
+        Infrequently used parameters can be described under this optional
+        section to prevent cluttering the Parameters section.
+    **kwargs : dict
+        Other infrequently used keyword arguments. Note that all keyword
+        arguments appearing after the first parameter specified under the
+        Other Parameters section, should also be described under this
+        section.
+
+    Raises
+    ------
+    BadException
+        Because you shouldn't have done that.
+
+    See Also
+    --------
+    numpy.array : Relationship (optional).
+    numpy.ndarray : Relationship (optional), which could be fairly long, in
+                    which case the line wraps here.
+    numpy.dot, numpy.linalg.norm, numpy.eye
+
+    Notes
+    -----
+    Notes about the implementation algorithm (if needed).
+
+    This can have multiple paragraphs.
+
+    You may include some math:
+
+    .. math:: X(e^{j\omega } ) = x(n)e^{ - j\omega n}
+
+    And even use a Greek symbol like :math:`\omega` inline.
+
+    References
+    ----------
+    Cite the relevant literature, e.g. [1]_.  You may also cite these
+    references in the notes section above.
+
+    .. [1] O. McNoleg, "The integration of GIS, remote sensing,
+       expert systems and adaptive co-kriging for environmental habitat
+       modelling of the Highland Haggis using object-oriented, fuzzy-logic
+       and neural-network techniques," Computers & Geosciences, vol. 22,
+       pp. 585-588, 1996.
+
+    Examples
+    --------
+    These are written in doctest format, and should illustrate how to
+    use the function.
+
+    >>> a = [1, 2, 3]
+    >>> print([x + 3 for x in a])
+    [4, 5, 6]
+    >>> print("a\nb")
+    a
+    b
+
+    """
+    # There should be no blank lines after closing the docstring for functions,
+    # methods, and modules.  
+    pass
+
+.. _numpydoc-style: https://numpydoc.readthedocs.io/en/latest/format.html
+
+Documentation is contained in the ``docs`` directory and extracted from
+docstrings of the API.
+
+Docs follow the underlining convention::
+
+    Headling 1 (page title)
+    =======================
+
+    Heading 2
+    ---------
+
+    Heading 3
+    ~~~~~~~~~
+
+.. seealso::
+
+    How-to guide `../how-to/build-docs`
\ No newline at end of file
diff --git a/docs/developer/tutorials/dev-install.rst b/docs/developer/tutorials/dev-install.rst
new file mode 100644
index 000000000..0fc66de5f
--- /dev/null
+++ b/docs/developer/tutorials/dev-install.rst
@@ -0,0 +1,45 @@
+Developer install
+=================
+
+These instructions will take you through the minimal steps required to get a dev
+environment setup, so you can run the tests locally.
+
+Clone the repository
+--------------------
+
+First clone the repository locally using `Git
+<https://git-scm.com/downloads>`_::
+
+    $ git clone git://github.com/bluesky/event-model.git
+
+Install dependencies
+--------------------
+
+You should install into a `venv` (which requires python 3.8 or later):
+.. code::
+
+    $ cd event-model
+    $ python3 -m venv venv
+    $ source venv/bin/activate
+    $ pip install -e '.[dev]'
+
+See what was installed
+----------------------
+
+To see a graph of the python package dependency tree type::
+
+    $ pipdeptree
+
+Build and test
+--------------
+
+Now you have a development environment you can run the tests in a terminal::
+
+    $ tox -p
+
+This will run in parallel the following checks:
+
+- `../how-to/build-docs`
+- `../how-to/run-tests`
+- `../how-to/static-analysis`
+- `../how-to/lint`
diff --git a/docs/genindex.rst b/docs/genindex.rst
new file mode 100644
index 000000000..93eb8b294
--- /dev/null
+++ b/docs/genindex.rst
@@ -0,0 +1,5 @@
+API Index
+=========
+
+..
+    https://stackoverflow.com/a/42310803
diff --git a/docs/images/bluesky-logo-dark.svg b/docs/images/bluesky-logo-dark.svg
new file mode 100644
index 000000000..0ec005008
--- /dev/null
+++ b/docs/images/bluesky-logo-dark.svg
@@ -0,0 +1,1 @@
+<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 288 90"><defs><style>.cls-1{fill:url(#linear-gradient);}.cls-2{fill:url(#linear-gradient-2);}.cls-3{fill:url(#linear-gradient-3);}.cls-4{fill:url(#linear-gradient-4);}.cls-5{fill:url(#linear-gradient-5);}.cls-6{fill:url(#linear-gradient-6);}.cls-7{fill:url(#linear-gradient-7);}.cls-8{fill:url(#linear-gradient-8);}</style><linearGradient id="linear-gradient" x1="115.44" y1="75.82" x2="115.44" y2="13.97" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5db0d8"/><stop offset="1" stop-color="#477fb9"/></linearGradient><linearGradient id="linear-gradient-2" x1="138.96" y1="75.82" x2="138.96" y2="13.97" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-3" x1="170.29" y1="75.82" x2="170.29" y2="13.97" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-4" x1="199.82" y1="75.82" x2="199.82" y2="13.97" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-5" x1="231.42" y1="75.82" x2="231.42" y2="13.97" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-6" x1="261" y1="75.82" x2="261" y2="13.97" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-7" x1="87.71" y1="75.82" x2="87.71" y2="13.97" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-8" x1="60.15" y1="75.82" x2="60.15" y2="13.97" xlink:href="#linear-gradient"/></defs><title>Bluesky_Logo_Final</title><path class="cls-1" d="M112.64,32.21a2.39,2.39,0,0,1,.79-2,3.43,3.43,0,0,1,2.21-.64,5.64,5.64,0,0,1,1.62.22q.74.22,1,.32V65.85h-5.61Z"/><path class="cls-2" d="M127,43.76a2.38,2.38,0,0,1,.79-2,3.52,3.52,0,0,1,2.21-.61,5.64,5.64,0,0,1,1.62.22c.49.15.82.25,1,.32V54.63a7.36,7.36,0,0,0,1.57,5.09,7.53,7.53,0,0,0,9.64,0,7.36,7.36,0,0,0,1.57-5.09V43.76a2.38,2.38,0,0,1,.79-2,3.43,3.43,0,0,1,2.16-.61,5.85,5.85,0,0,1,1.67.22c.49.15.82.25,1,.32V55.86a11.25,11.25,0,0,1-.86,4.5,9.2,9.2,0,0,1-2.46,3.37,10.87,10.87,0,0,1-3.81,2.09,17,17,0,0,1-9.74,0,10.6,10.6,0,0,1-3.79-2.09,9.57,9.57,0,0,1-2.46-3.37,11,11,0,0,1-.89-4.5Z"/><path class="cls-3" d="M181.33,64q-.39.25-1.23.69a12.54,12.54,0,0,1-2.09.84,23.67,23.67,0,0,1-2.9.69,20.55,20.55,0,0,1-3.62.3,15.44,15.44,0,0,1-5.14-.84,11.94,11.94,0,0,1-4.18-2.48,11.7,11.7,0,0,1-2.83-4,13.69,13.69,0,0,1-1-5.48,13.94,13.94,0,0,1,1-5.39,12.31,12.31,0,0,1,2.8-4.18,12.62,12.62,0,0,1,4.23-2.71,14.43,14.43,0,0,1,5.34-1,14.15,14.15,0,0,1,4.53.66,10.48,10.48,0,0,1,3.32,1.77,7.24,7.24,0,0,1,2.73,5.73,7.46,7.46,0,0,1-.64,3.12,5.65,5.65,0,0,1-2.26,2.41A13.51,13.51,0,0,1,175,55.74a35.68,35.68,0,0,1-7,.57l-1.84,0-1.84-.07a6.11,6.11,0,0,0,2.44,4.06,9.48,9.48,0,0,0,5.83,1.55,13.21,13.21,0,0,0,2.36-.2,13.44,13.44,0,0,0,1.94-.49,13.82,13.82,0,0,0,1.48-.59l.91-.44ZM164.07,52.37q1.13,0,2.16,0h2.07a22.44,22.44,0,0,0,4.38-.34,8.85,8.85,0,0,0,2.61-.89A2.76,2.76,0,0,0,176.51,50a3.59,3.59,0,0,0,.29-1.43,3.09,3.09,0,0,0-1.5-2.8,6.9,6.9,0,0,0-3.71-.93,7.52,7.52,0,0,0-3.2.64,6.94,6.94,0,0,0-2.31,1.7,7.78,7.78,0,0,0-1.45,2.44A8.79,8.79,0,0,0,164.07,52.37Z"/><path class="cls-4" d="M190,59.7a7.65,7.65,0,0,0,1.23.64,21.88,21.88,0,0,0,2.19.81,22.81,22.81,0,0,0,3,.71,21,21,0,0,0,3.64.29,11.17,11.17,0,0,0,4.18-.66,2.33,2.33,0,0,0,1.62-2.29,2.38,2.38,0,0,0-1-2.09,8.6,8.6,0,0,0-3.69-1l-3.84-.44a18,18,0,0,1-3.61-.71,9.3,9.3,0,0,1-2.83-1.4A6.24,6.24,0,0,1,189,51.36a6.8,6.8,0,0,1-.66-3.12,7.31,7.31,0,0,1,.62-3,6.64,6.64,0,0,1,1.94-2.48,9.66,9.66,0,0,1,3.47-1.7,19,19,0,0,1,5.19-.62,22.13,22.13,0,0,1,7.94,1.13q2.83,1.13,2.83,3a2.28,2.28,0,0,1-.22,1,4.62,4.62,0,0,1-.49.79,3.08,3.08,0,0,1-.57.57,1.85,1.85,0,0,1-.44.27,5.79,5.79,0,0,0-.86-.62,9.88,9.88,0,0,0-1.8-.84,18.71,18.71,0,0,0-2.7-.74,18.45,18.45,0,0,0-3.64-.32q-5.71,0-5.71,3A2.14,2.14,0,0,0,195,49.64a7.8,7.8,0,0,0,3.1.91l5,.64a13.44,13.44,0,0,1,6.34,2.24,5.85,5.85,0,0,1,2.26,5,7,7,0,0,1-2.93,5.9q-2.93,2.21-8.43,2.21a27.07,27.07,0,0,1-4.82-.39,29.6,29.6,0,0,1-3.76-.91,18.9,18.9,0,0,1-2.58-1,7.77,7.77,0,0,1-1.28-.74Z"/><path class="cls-5" d="M219.15,32.21a2.38,2.38,0,0,1,.79-2,3.43,3.43,0,0,1,2.21-.64,5.64,5.64,0,0,1,1.62.22q.74.22,1,.32V50.23q.64-.44,1.8-1.3L229.13,47l3-2.16q1.52-1.13,2.9-2.11a12.5,12.5,0,0,1,2-1.2,5.46,5.46,0,0,1,2.31-.42,3.62,3.62,0,0,1,2.26.71,4.14,4.14,0,0,1,1.18,1.2q-1,.93-2.83,2.28t-3.66,2.7q-1.87,1.35-3.54,2.5t-2.41,1.69A59.1,59.1,0,0,1,237.89,59a68.79,68.79,0,0,1,5.8,6.87h-7q-1.13-1.38-2.53-2.88t-3-3q-1.6-1.45-3.25-2.81t-3.17-2.49V65.85h-5.61Z"/><path class="cls-6" d="M249,43.76a2.38,2.38,0,0,1,.79-2,3.34,3.34,0,0,1,2.16-.64,5.89,5.89,0,0,1,1.65.22q.76.22,1,.32V54.83a6.65,6.65,0,0,0,1.55,4.62,6.13,6.13,0,0,0,4.79,1.72,6.33,6.33,0,0,0,4.72-1.75,7,7,0,0,0,1.72-5.14V43.76a2.38,2.38,0,0,1,.79-2,3.34,3.34,0,0,1,2.16-.64,5.9,5.9,0,0,1,1.65.22q.76.22,1,.32V63.24a16.61,16.61,0,0,1-.93,5.93,10,10,0,0,1-6.79,6.3,18.87,18.87,0,0,1-5.36.71,28.17,28.17,0,0,1-3.69-.22q-1.67-.22-3-.52t-2.14-.57q-.84-.27-1.18-.42.39-1,.74-2.09t.69-2.09c.23.1.59.23,1.08.39a17.17,17.17,0,0,0,1.77.47q1,.22,2.34.37a24.77,24.77,0,0,0,2.78.15,13.07,13.07,0,0,0,3.44-.42,5.83,5.83,0,0,0,2.58-1.45A6.71,6.71,0,0,0,267,67.05a13.68,13.68,0,0,0,.57-4.25,6.69,6.69,0,0,1-3.07,2.29,12,12,0,0,1-4.6.86,13.92,13.92,0,0,1-4.38-.66A9.77,9.77,0,0,1,252,63.37a8.71,8.71,0,0,1-2.24-3.12A10.66,10.66,0,0,1,249,56Z"/><path class="cls-7" d="M93.21,47.77A7.4,7.4,0,0,0,90.8,46.1a7.57,7.57,0,0,0-3.07-.62,7.7,7.7,0,0,0-3.12.62,7,7,0,0,0-2.39,1.7,8,8,0,0,0-1.55,2.56,8.72,8.72,0,0,0-.57,3.15,9,9,0,0,0,.57,3.25,7.07,7.07,0,0,0,4,4.13,7.84,7.84,0,0,0,3.07.59,7.56,7.56,0,0,0,3.07-.62,7.38,7.38,0,0,0,2.41-1.67,7.27,7.27,0,0,0,1.55-2.51,9,9,0,0,0,.54-3.17,9.23,9.23,0,0,0-.54-3.2A7.24,7.24,0,0,0,93.21,47.77Z"/><path class="cls-8" d="M88.4,36.36c-.24,0-.48,0-.72,0a28.18,28.18,0,0,0-52.29-8,20.89,20.89,0,0,0,.51,41.77H88.4a16.9,16.9,0,1,0,0-33.81Zm11.73,22.36a12,12,0,0,1-2.75,4.13,12.87,12.87,0,0,1-4.21,2.71,14.12,14.12,0,0,1-5.34,1,15.51,15.51,0,0,1-5.34-.89A11.35,11.35,0,0,1,78.29,63a12.09,12.09,0,0,1-2.75-4.43,18,18,0,0,1-1-6.25V32.21a2.39,2.39,0,0,1,.79-2,3.42,3.42,0,0,1,2.21-.64,5.63,5.63,0,0,1,1.62.22q.74.22,1,.32V44.26a10,10,0,0,1,3.39-2.68,10.76,10.76,0,0,1,5-1.11,12.78,12.78,0,0,1,5,1,11.93,11.93,0,0,1,4,2.71,12.6,12.6,0,0,1,2.66,4.13,13.92,13.92,0,0,1,1,5.24A13.67,13.67,0,0,1,100.12,58.72Z"/></svg>
\ No newline at end of file
diff --git a/docs/source/_static/document-generation-timeline.svg b/docs/images/document-generation-timeline.svg
similarity index 100%
rename from docs/source/_static/document-generation-timeline.svg
rename to docs/images/document-generation-timeline.svg
diff --git a/docs/source/index.rst b/docs/index.rst
similarity index 66%
rename from docs/source/index.rst
rename to docs/index.rst
index d12262b64..713e5d9f7 100644
--- a/docs/source/index.rst
+++ b/docs/index.rst
@@ -1,7 +1,7 @@
-.. Packaging Scientific Python documentation master file, created by
-   sphinx-quickstart on Thu Jun 28 12:35:56 2018.
-   You can adapt this file completely to your liking, but it should at least
-   contain the root `toctree` directive.
+:html_theme.sidebar_secondary.remove:
+
+.. include:: ../README.rst
+    :end-before: when included in index.rst
 
 ===================================
  Bluesky Event Model Documentation
@@ -16,8 +16,6 @@ This repository contains the formal schemas for bluesky's streaming data model
 and some Python tooling for composing, validating, and transforming documents
 in the model.
 
-
-
 Where is my data?
 =================
 
@@ -35,13 +33,27 @@ you should look for / put different kinds of information
 * Device configuration data  → *Event Descriptor* Document in the *configuration* entry
 
 
+How the documentation is structured
+===================================
+
+The documentation is split into 2 sections:
+
+.. grid:: 2
+
+    .. grid-item-card:: :material-regular:`person;4em`
+        :link: user/index
+        :link-type: doc
+
+        The User Guide contains documentation on how to install and use event-model.
+
+    .. grid-item-card:: :material-regular:`code;4em`
+        :link: developer/index
+        :link-type: doc
+
+        The Developer Guide contains documentation on how to develop and contribute changes back to event-model.
 
 .. toctree::
-   :maxdepth: 2
-
-   installation
-   data-model
-   api
-   external
-   use-cases
-   release-history
+    :hidden:
+
+    user/index
+    developer/index
diff --git a/docs/source/conf.py b/docs/source/conf.py
deleted file mode 100644
index 54702fab8..000000000
--- a/docs/source/conf.py
+++ /dev/null
@@ -1,204 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-#
-# Bluesky Event Model documentation build configuration file, created by
-# sphinx-quickstart on Thu Jun 28 12:35:56 2018.
-#
-# This file is execfile()d with the current directory set to its
-# containing dir.
-#
-# Note that not all possible configuration values are present in this
-# autogenerated file.
-#
-# All configuration values have a default; values that are commented out
-# serve to show the default.
-
-# If extensions (or modules to document with autodoc) are in another directory,
-# add these directories to sys.path here. If the directory is relative to the
-# documentation root, use os.path.abspath to make it absolute, like shown here.
-#
-# import os
-# import sys
-# sys.path.insert(0, os.path.abspath('.'))
-
-
-# -- General configuration ------------------------------------------------
-
-# If your documentation needs a minimal Sphinx version, state it here.
-#
-# needs_sphinx = '1.0'
-
-# Add any Sphinx extension module names here, as strings. They can be
-# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
-# ones.
-extensions = [
-    'sphinx.ext.autodoc',
-    'sphinx.ext.autosummary',
-    'sphinx.ext.githubpages',
-    'sphinx.ext.intersphinx',
-    'sphinx.ext.mathjax',
-    'sphinx.ext.viewcode',
-    'IPython.sphinxext.ipython_directive',
-    'IPython.sphinxext.ipython_console_highlighting',
-    'matplotlib.sphinxext.plot_directive',
-    'numpydoc',
-    'sphinx_copybutton',
-]
-
-# Configuration options for plot_directive. See:
-# https://github.com/matplotlib/matplotlib/blob/f3ed922d935751e08494e5fb5311d3050a3b637b/lib/matplotlib/sphinxext/plot_directive.py#L81
-plot_html_show_source_link = False
-plot_html_show_formats = False
-
-# Generate the API documentation when building
-autosummary_generate = True
-numpydoc_show_class_members = False
-
-# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
-
-# The suffix(es) of source filenames.
-# You can specify multiple suffix as a list of string:
-#
-# source_suffix = ['.rst', '.md']
-source_suffix = '.rst'
-
-# The master toctree document.
-master_doc = 'index'
-
-# General information about the project.
-project = 'Bluesky Event Model'
-copyright = '2019, Brookhaven National Lab'
-author = 'Brookhaven National Lab'
-
-# The version info for the project you're documenting, acts as replacement for
-# |version| and |release|, also used in various other places throughout the
-# built documents.
-#
-import event_model
-# The short X.Y version.
-version = event_model.__version__
-# The full version, including alpha/beta/rc tags.
-release = event_model.__version__
-
-# The language for content autogenerated by Sphinx. Refer to documentation
-# for a list of supported languages.
-#
-# This is also used if you do content translation via gettext catalogs.
-# Usually you set "language" from the command line for these cases.
-language = 'en'
-
-# List of patterns, relative to source directory, that match files and
-# directories to ignore when looking for source files.
-# This patterns also effect to html_static_path and html_extra_path
-exclude_patterns = []
-
-# The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'sphinx'
-
-# If true, `todo` and `todoList` produce output, else they produce nothing.
-todo_include_todos = False
-
-
-# -- Options for HTML output ----------------------------------------------
-
-# The theme to use for HTML and HTML Help pages.  See the documentation for
-# a list of builtin themes.
-#
-html_theme = 'sphinx_rtd_theme'
-import sphinx_rtd_theme
-html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
-
-# Theme options are theme-specific and customize the look and feel of a theme
-# further.  For a list of options available for each theme, see the
-# documentation.
-#
-# html_theme_options = {}
-
-# Add any paths that contain custom static files (such as style sheets) here,
-# relative to this directory. They are copied after the builtin static files,
-# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
-
-# Custom sidebar templates, must be a dictionary that maps document names
-# to template names.
-#
-# This is required for the alabaster theme
-# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
-html_sidebars = {
-    '**': [
-        'relations.html',  # needs 'show_related': True theme option to display
-        'searchbox.html',
-    ]
-}
-
-
-# -- Options for HTMLHelp output ------------------------------------------
-
-# Output file base name for HTML help builder.
-htmlhelp_basename = 'event-model'
-
-
-# -- Options for LaTeX output ---------------------------------------------
-
-latex_elements = {
-    # The paper size ('letterpaper' or 'a4paper').
-    #
-    # 'papersize': 'letterpaper',
-
-    # The font size ('10pt', '11pt' or '12pt').
-    #
-    # 'pointsize': '10pt',
-
-    # Additional stuff for the LaTeX preamble.
-    #
-    # 'preamble': '',
-
-    # Latex figure (float) alignment
-    #
-    # 'figure_align': 'htbp',
-}
-
-# Grouping the document tree into LaTeX files. List of tuples
-# (source start file, target name, title,
-#  author, documentclass [howto, manual, or own class]).
-latex_documents = [
-    (master_doc, 'event-model.tex', 'Bluesky Event Model Documentation',
-     'Contributors', 'manual'),
-]
-
-
-# -- Options for manual page output ---------------------------------------
-
-# One entry per manual page. List of tuples
-# (source start file, name, description, authors, manual section).
-man_pages = [
-    (master_doc, 'event-model', 'Bluesky Event Model Documentation',
-     [author], 1)
-]
-
-
-# -- Options for Texinfo output -------------------------------------------
-
-# Grouping the document tree into Texinfo files. List of tuples
-# (source start file, target name, title, author,
-#  dir menu entry, description, category)
-texinfo_documents = [
-    (master_doc, 'event-model', 'Bluesky Event Model Documentation',
-     author, 'event-model', 'Data model used by the bluesky ecosystem',
-     'Miscellaneous'),
-]
-
-
-
-
-# Example configuration for intersphinx: refer to the Python standard library.
-intersphinx_mapping = {
-    'python': ('https://docs.python.org/3/', None),
-    'cachetools': ('https://cachetools.readthedocs.io/en/stable/', None),
-    'numpy': ('https://docs.scipy.org/doc/numpy/', None),
-    'scipy': ('https://docs.scipy.org/doc/scipy/reference/', None),
-    'pandas': ('https://pandas.pydata.org/pandas-docs/stable', None),
-    'matplotlib': ('https://matplotlib.org', None),
-    'jsonschema': ('https://python-jsonschema.readthedocs.io/en/stable/', None),
-}
diff --git a/docs/source/installation.rst b/docs/source/installation.rst
deleted file mode 100644
index 186098f5a..000000000
--- a/docs/source/installation.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-============
-Installation
-============
-
-Using pip::
-
-    pip install event-model
diff --git a/docs/source/data-model.rst b/docs/user/explanations/data-model.rst
similarity index 96%
rename from docs/source/data-model.rst
rename to docs/user/explanations/data-model.rst
index 3b8dc1bf3..dc151b044 100644
--- a/docs/source/data-model.rst
+++ b/docs/user/explanations/data-model.rst
@@ -59,7 +59,7 @@ Resource and Datum document types manage references to externally-stored data.
 Example Runs
 ============
 
-.. image:: _static/document-generation-timeline.svg
+.. image:: ../../images/document-generation-timeline.svg
    :width: 100%
    :align: center
 
@@ -162,7 +162,7 @@ experimetal and subject to backward-incompatible changes in future releases.
 
 The run start document formal schema:
 
-.. literalinclude:: ../../event_model/schemas/run_start.json
+.. literalinclude:: ../../../event_model/schemas/run_start.json
 
 .. _descriptor:
 
@@ -241,7 +241,7 @@ Typical example:
 
 Formal schema:
 
-.. literalinclude:: ../../event_model/schemas/event_descriptor.json
+.. literalinclude:: ../../../event_model/schemas/event_descriptor.json
 
 .. _event:
 
@@ -298,7 +298,7 @@ overall event 'time' is often more useful.
 
 Formal schema:
 
-.. literalinclude:: ../../event_model/schemas/event.json
+.. literalinclude:: ../../../event_model/schemas/event.json
 
 .. _event_page:
 
@@ -326,7 +326,7 @@ the example Event above structured as an Event Page with a single row:
 
 Formal Event Page schema:
 
-.. literalinclude:: ../../event_model/schemas/event_page.json
+.. literalinclude:: ../../../event_model/schemas/event_page.json
 
 It is intentional that the values in the "data" and "timestamps" dictionaries
 do not have structure. The values may be numeric, bool, null (``None``), or a
@@ -375,7 +375,7 @@ Typical example:
 
 Formal schema:
 
-.. literalinclude:: ../../event_model/schemas/run_stop.json
+.. literalinclude:: ../../../event_model/schemas/run_stop.json
 
 .. _resource:
 
@@ -413,7 +413,7 @@ Typical example:
 
 Formal schema:
 
-.. literalinclude:: ../../event_model/schemas/resource.json
+.. literalinclude:: ../../../event_model/schemas/resource.json
 
 .. _datum:
 
@@ -446,7 +446,7 @@ It is an implementation detail that ``datum_id`` is often formatted as
 
 Formal schema:
 
-.. literalinclude:: ../../event_model/schemas/datum.json
+.. literalinclude:: ../../../event_model/schemas/datum.json
 
 .. _datum_page:
 
@@ -466,7 +466,7 @@ strucuted as a Datum Page with one row:
 
 Formal Datum Page schema:
 
-.. literalinclude:: ../../event_model/schemas/datum_page.json
+.. literalinclude:: ../../../event_model/schemas/datum_page.json
 
 .. _stream_resource:
 
@@ -507,7 +507,7 @@ Typical example:
 
 Formal schema:
 
-.. literalinclude:: ../../event_model/schemas/stream_resource.json
+.. literalinclude:: ../../../event_model/schemas/stream_resource.json
 
 .. _stream_datum:
 
@@ -547,7 +547,7 @@ It is an implementation detail that ``datum_id`` is often formatted as
 
 Formal schema:
 
-.. literalinclude:: ../../event_model/schemas/stream_datum.json
+.. literalinclude:: ../../../event_model/schemas/stream_datum.json
 
 .. _bulk_events:
 
@@ -557,7 +557,7 @@ Formal schema:
 This is another representation of Events. This representation is deprecated.
 Use EventPage instead.
 
-.. literalinclude:: ../../event_model/schemas/bulk_events.json
+.. literalinclude:: ../../../event_model/schemas/bulk_events.json
 
 .. _bulk_datum:
 
@@ -567,4 +567,4 @@ Use EventPage instead.
 This is another representation of Datum. This representation is deprecated. Use
 DatumPage instead.
 
-.. literalinclude:: ../../event_model/schemas/bulk_datum.json
+.. literalinclude:: ../../../event_model/schemas/bulk_datum.json
diff --git a/docs/user/explanations/docs-structure.rst b/docs/user/explanations/docs-structure.rst
new file mode 100644
index 000000000..e3487c27d
--- /dev/null
+++ b/docs/user/explanations/docs-structure.rst
@@ -0,0 +1,18 @@
+About The Documentation
+-----------------------
+
+  :material-regular:`format_quote;2em`
+
+  The Grand Unified Theory of Documentation
+
+  -- David Laing
+
+There is a secret that needs to be understood in order to write good software
+documentation: there isn't one thing called *documentation*, there are four.
+
+They are: *tutorials*, *how-to guides*, *technical reference* and *explanation*.
+They represent four different purposes or functions, and require four different
+approaches to their creation. Understanding the implications of this will help
+improve most documentation - often immensely.
+
+`More information on this topic. <https://documentation.divio.com>`_
diff --git a/docs/source/event-descriptors.rst b/docs/user/explanations/event-descriptors.rst
similarity index 100%
rename from docs/source/event-descriptors.rst
rename to docs/user/explanations/event-descriptors.rst
diff --git a/docs/source/external.rst b/docs/user/explanations/external.rst
similarity index 100%
rename from docs/source/external.rst
rename to docs/user/explanations/external.rst
diff --git a/docs/source/_static/.placeholder b/docs/user/how-to/run-container.rst
similarity index 100%
rename from docs/source/_static/.placeholder
rename to docs/user/how-to/run-container.rst
diff --git a/docs/source/use-cases.rst b/docs/user/how-to/use-cases.rst
similarity index 100%
rename from docs/source/use-cases.rst
rename to docs/user/how-to/use-cases.rst
diff --git a/docs/user/index.rst b/docs/user/index.rst
new file mode 100644
index 000000000..2cc02144b
--- /dev/null
+++ b/docs/user/index.rst
@@ -0,0 +1,60 @@
+User Guide
+==========
+
+Documentation is split into four categories, also accessible from links in the
+side-bar.
+
+.. grid:: 2
+    :gutter: 4
+
+    .. grid-item-card:: :material-regular:`directions_walk;3em`
+
+        .. toctree::
+            :caption: Tutorials
+            :maxdepth: 1
+
+            tutorials/installation
+
+        +++
+
+        Tutorials for installation and typical usage. New users start here.
+
+    .. grid-item-card:: :material-regular:`directions;3em`
+
+        .. toctree::
+            :caption: How-to Guides
+            :maxdepth: 1
+
+            how-to/use-cases
+
+
+        +++
+
+        Practical step-by-step guides for the more experienced user.
+
+    .. grid-item-card:: :material-regular:`info;3em`
+
+        .. toctree::
+            :caption: Explanations
+            :maxdepth: 1
+
+            explanations/docs-structure
+            explanations/data-model
+            explanations/external
+
+        +++
+
+        Explanations of how the library works and why it works that way.
+
+    .. grid-item-card:: :material-regular:`menu_book;3em`
+
+        .. toctree::
+            :caption: Reference
+            :maxdepth: 1
+
+            reference/api
+            ../genindex
+
+        +++
+
+        Technical reference material including APIs and release notes.
diff --git a/docs/source/api.rst b/docs/user/reference/api.rst
similarity index 89%
rename from docs/source/api.rst
rename to docs/user/reference/api.rst
index ddc835b2b..ce598f808 100644
--- a/docs/source/api.rst
+++ b/docs/user/reference/api.rst
@@ -1,6 +1,14 @@
-===================
- API Documentation
-===================
+=================
+API Documentation
+=================
+
+This is the internal API reference for event_model
+
+.. data:: event_model.__version__
+    :type: str
+
+    Version number as calculated by https://github.com/pypa/setuptools_scm
+
 
 Schemas and Names
 =================
@@ -95,4 +103,4 @@ Document Munging
 .. autofunction:: event_model.verify_filled
 
 .. autoclass:: event_model.NumpyEncoder
-   :members:
+   :members:
\ No newline at end of file
diff --git a/docs/user/tutorials/installation.rst b/docs/user/tutorials/installation.rst
new file mode 100644
index 000000000..e47329625
--- /dev/null
+++ b/docs/user/tutorials/installation.rst
@@ -0,0 +1,38 @@
+Installation
+============
+
+Check your version of python
+----------------------------
+
+You will need python 3.8 or later. You can check your version of python by
+typing into a terminal::
+
+    $ python3 --version
+
+
+Create a virtual environment
+----------------------------
+
+It is recommended that you install into a “virtual environment” so this
+installation will not interfere with any existing Python software::
+
+    $ python3 -m venv /path/to/venv
+    $ source /path/to/venv/bin/activate
+
+
+Installing the library
+----------------------
+
+You can now use ``pip`` to install the library and its dependencies::
+
+    $ python3 -m pip install event-model
+
+If you require a feature that is not currently released you can also install
+from github::
+
+    $ python3 -m pip install git+https://github.com/bluesky/event-model.git
+
+The library should now be installed and the commandline interface on your path.
+You can check the version that has been installed by typing::
+
+    $ python -m event_model --version
diff --git a/event_model/__init__.py b/event_model/__init__.py
index 938f27d22..e294c8700 100644
--- a/event_model/__init__.py
+++ b/event_model/__init__.py
@@ -1,15 +1,9 @@
-from collections import defaultdict, deque, namedtuple
 import collections.abc
-from dataclasses import dataclass
-from typing import Optional
 import copy
-import json
-from enum import Enum
-from functools import partial
-import itertools
 import inspect
+import itertools
+import json
 import os
-from pkg_resources import resource_filename as rs_fn
 import sys
 import threading
 import time as ttime
@@ -17,10 +11,16 @@
 import uuid
 import warnings
 import weakref
-from packaging import version
+from collections import defaultdict, deque, namedtuple
+from dataclasses import dataclass
+from enum import Enum
+from functools import partial
+from typing import Optional
 
 import jsonschema
 import numpy
+from packaging import version
+from pkg_resources import resource_filename as rs_fn
 
 from ._version import get_versions
 
@@ -29,22 +29,22 @@
 else:
     from importlib.metadata import metadata
 
-__all__ = ['DocumentNames', 'schemas', 'schema_validators', 'compose_run']
+__all__ = ["DocumentNames", "schemas", "schema_validators", "compose_run"]
 
 
 class DocumentNames(Enum):
-    stop = 'stop'
-    start = 'start'
-    descriptor = 'descriptor'
-    event = 'event'
-    datum = 'datum'
-    resource = 'resource'
-    event_page = 'event_page'
-    datum_page = 'datum_page'
-    stream_resource = 'stream_resource'
-    stream_datum = 'stream_datum'
-    bulk_datum = 'bulk_datum'  # deprecated
-    bulk_events = 'bulk_events'  # deprecated
+    stop = "stop"
+    start = "start"
+    descriptor = "descriptor"
+    event = "event"
+    datum = "datum"
+    resource = "resource"
+    event_page = "event_page"
+    datum_page = "datum_page"
+    stream_resource = "stream_resource"
+    stream_datum = "stream_datum"
+    bulk_datum = "bulk_datum"  # deprecated
+    bulk_events = "bulk_events"  # deprecated
 
 
 class DocumentRouter:
@@ -72,6 +72,7 @@ class DocumentRouter:
     emit: callable, optional
         Expected signature ``f(name, doc)``
     """
+
     def __init__(self, *, emit=None):
         # Put in some extra effort to validate `emit` carefully, because if
         # this is used incorrectly the resultant errors can be confusing.
@@ -83,7 +84,9 @@ def __init__(self, *, emit=None):
                 # Does this function accept two positional arguments?
                 sig.bind(None, None)
             except TypeError:
-                raise ValueError("emit must accept two positional arguments, name and doc")
+                raise ValueError(
+                    "emit must accept two positional arguments, name and doc"
+                )
             # Stash a weak reference to `emit`.
             if inspect.ismethod(emit):
                 self._emit_ref = weakref.WeakMethod(emit)
@@ -133,23 +136,27 @@ def _dispatch(self, name, doc, validate):
         # If 'event' is not defined by the subclass but 'event_page' is, or
         # vice versa, use that. And the same for 'datum_page' / 'datum.
         if output_doc is NotImplemented:
-            if name == 'event':
+            if name == "event":
                 event_page = pack_event_page(doc)
                 # Subclass' implementation of event_page may return a valid
                 # EventPage or None or NotImplemented.
                 output_event_page = self.event_page(event_page)
-                output_event_page = output_event_page if output_event_page is not None else event_page
+                output_event_page = (
+                    output_event_page if output_event_page is not None else event_page
+                )
                 if output_event_page is not NotImplemented:
-                    output_doc, = unpack_event_page(output_event_page)
-            elif name == 'datum':
+                    (output_doc,) = unpack_event_page(output_event_page)
+            elif name == "datum":
                 datum_page = pack_datum_page(doc)
                 # Subclass' implementation of datum_page may return a valid
                 # DatumPage or None or NotImplemented.
                 output_datum_page = self.datum_page(datum_page)
-                output_datum_page = output_datum_page if output_datum_page is not None else datum_page
+                output_datum_page = (
+                    output_datum_page if output_datum_page is not None else datum_page
+                )
                 if output_datum_page is not NotImplemented:
-                    output_doc, = unpack_datum_page(output_datum_page)
-            elif name == 'event_page':
+                    (output_doc,) = unpack_datum_page(output_datum_page)
+            elif name == "event_page":
                 output_events = []
                 for event in unpack_event_page(doc):
                     # Subclass' implementation of event may return a valid
@@ -161,7 +168,7 @@ def _dispatch(self, name, doc, validate):
                     output_events.append(output_event)
                 else:
                     output_doc = pack_event_page(*output_events)
-            elif name == 'datum_page':
+            elif name == "datum_page":
                 output_datums = []
                 for datum in unpack_datum_page(doc):
                     # Subclass' implementation of datum may return a valid
@@ -223,7 +230,8 @@ def bulk_events(self, doc):
         # Do not modify this in a subclass. Use event_page.
         warnings.warn(
             "The document type 'bulk_events' has been deprecated in favor of "
-            "'event_page', whose structure is a transpose of 'bulk_events'.")
+            "'event_page', whose structure is a transpose of 'bulk_events'."
+        )
         for page in bulk_events_to_event_pages(doc):
             self.event_page(page)
 
@@ -231,7 +239,8 @@ def bulk_datum(self, doc):
         # Do not modify this in a subclass. Use event_page.
         warnings.warn(
             "The document type 'bulk_datum' has been deprecated in favor of "
-            "'datum_page', whose structure is a transpose of 'bulk_datum'.")
+            "'datum_page', whose structure is a transpose of 'bulk_datum'."
+        )
         self.datum_page(bulk_datum_to_datum_page(doc))
 
 
@@ -239,6 +248,7 @@ class SingleRunDocumentRouter(DocumentRouter):
     """
     A DocumentRouter intended to process events from exactly one run.
     """
+
     def __init__(self):
         super().__init__()
         self._start_doc = None
@@ -265,21 +275,24 @@ def __call__(self, name, doc, validate=False):
             The same name as what was passed in, and a doc that may be the same
             instance as doc, a copy of doc, or a different dict altogether.
         """
-        if name == 'start':
+        if name == "start":
             if self._start_doc is None:
                 self._start_doc = doc
             else:
                 raise EventModelValueError(
-                    f'SingleRunDocumentRouter associated with start document {self._start_doc["uid"]} '
+                    "SingleRunDocumentRouter associated with start document "
+                    f'{self._start_doc["uid"]} '
                     f'received a second start document with uid {doc["uid"]}'
                 )
-        elif name == 'descriptor':
-            if doc['run_start'] == self._start_doc['uid']:
-                self._descriptors[doc['uid']] = doc
+        elif name == "descriptor":
+            if doc["run_start"] == self._start_doc["uid"]:
+                self._descriptors[doc["uid"]] = doc
             else:
                 raise EventModelValueError(
-                    f'SingleRunDocumentRouter associated with start document {self._start_doc["uid"]} '
-                    f'received a descriptor {doc["uid"]} associated with start document {doc["run_start"]}'
+                    "SingleRunDocumentRouter associated with start document "
+                    f'{self._start_doc["uid"]} '
+                    f'received a descriptor {doc["uid"]} associated with '
+                    f'start document {doc["run_start"]}'
                 )
         # Defer to superclass for dispatch/processing.
         return super().__call__(name, doc, validate)
@@ -294,12 +307,15 @@ def get_start(self):
         start document : dict
         """
         if self._start_doc is None:
-            raise EventModelError('SingleRunDocumentRouter has not processed a start document yet')
+            raise EventModelError(
+                "SingleRunDocumentRouter has not processed a start document yet"
+            )
 
         return self._start_doc
 
     def get_descriptor(self, doc):
-        """Convenience method returning the descriptor associated with the specified document.
+        """Convenience method returning the descriptor associated with the
+        specified document.
 
         Parameters
         ----------
@@ -310,17 +326,21 @@ def get_descriptor(self, doc):
         -------
         descriptor document : dict
         """
-        if 'descriptor' not in doc:
-            raise EventModelValueError(f'document is not associated with a descriptor:\n{doc}')
-        elif doc['descriptor'] not in self._descriptors:
+        if "descriptor" not in doc:
+            raise EventModelValueError(
+                f"document is not associated with a descriptor:\n{doc}"
+            )
+        elif doc["descriptor"] not in self._descriptors:
             raise EventModelValueError(
-                f'SingleRunDocumentRouter has not processed a descriptor with uid {doc["descriptor"]}'
+                "SingleRunDocumentRouter has not processed a descriptor with "
+                f'uid {doc["descriptor"]}'
             )
 
-        return self._descriptors[doc['descriptor']]
+        return self._descriptors[doc["descriptor"]]
 
     def get_stream_name(self, doc):
-        """Convenience method returning the name of the stream for the specified document.
+        """Convenience method returning the name of the stream for the
+        specified document.
 
         Parameters
         ----------
@@ -331,7 +351,7 @@ def get_stream_name(self, doc):
         -------
         stream name : str
         """
-        return self.get_descriptor(doc).get('name')
+        return self.get_descriptor(doc).get("name")
 
 
 class HandlerRegistryView(collections.abc.Mapping):
@@ -353,12 +373,15 @@ def __len__(self):
     def __setitem__(self, key, value):
         raise EventModelTypeError(
             "The handler registry cannot be edited directly. "
-            "Instead, use the method Filler.register_handler.")
+            "Instead, use the method Filler.register_handler."
+        )
 
     def __delitem__(self, key):
         raise EventModelTypeError(
             "The handler registry cannot be edited directly. "
-            "Instead, use the method Filler.deregister_handler.")
+            "Instead, use the method Filler.deregister_handler."
+        )
+
 
 # A "coercion funcion" is a hook that Filler can use to, for example, ensure
 # all the external data read in my handlers is an *actual* numpy array as
@@ -396,19 +419,20 @@ def as_is(handler_class, filler_state):
 
 def force_numpy(handler_class, filler_state):
     "A coercion that makes handler_class.__call__ return actual numpy.ndarray."
+
     class Subclass(handler_class):
         def __call__(self, *args, **kwargs):
             raw_result = super().__call__(*args, **kwargs)
             result_as_array = numpy.asarray(raw_result)
             return result_as_array
+
     Subclass.__name__ = f"Subclassed{handler_class.__name__}"
     Subclass.__qualname__ = f"Subclassed{handler_class.__qualname__}"
     return Subclass
 
 
 # maps coerce option to corresponding coercion function
-_coercion_registry = {'as_is': as_is,
-                      'force_numpy': force_numpy}
+_coercion_registry = {"as_is": as_is, "force_numpy": force_numpy}
 
 
 def register_coercion(name, func, overwrite=False):
@@ -438,7 +462,8 @@ def register_coercion(name, func, overwrite=False):
         raise EventModelValueError(
             f"The coercion function {func} could not be registered for the "
             f"name {name} because {_coercion_registry[name]} is already "
-            f"registered. Use overwrite=True to force it.")
+            f"registered. Use overwrite=True to force it."
+        )
     _coercion_registry[name] = func
 
 
@@ -543,19 +568,43 @@ class Filler(DocumentRouter):
     ...
     >>> del filler  # Free up memory from potentially large caches.
     """
-    def __init__(self, handler_registry, *,
-                 include=None, exclude=None, root_map=None, coerce='as_is',
-                 handler_cache=None, resource_cache=None, datum_cache=None,
-                 descriptor_cache=None, stream_resource_cache=None,
-                 stream_datum_cache=None, inplace=None,
-                 retry_intervals=(0.001, 0.002, 0.004, 0.008, 0.016, 0.032,
-                                  0.064, 0.128, 0.256, 0.512, 1.024)):
+
+    def __init__(
+        self,
+        handler_registry,
+        *,
+        include=None,
+        exclude=None,
+        root_map=None,
+        coerce="as_is",
+        handler_cache=None,
+        resource_cache=None,
+        datum_cache=None,
+        descriptor_cache=None,
+        stream_resource_cache=None,
+        stream_datum_cache=None,
+        inplace=None,
+        retry_intervals=(
+            0.001,
+            0.002,
+            0.004,
+            0.008,
+            0.016,
+            0.032,
+            0.064,
+            0.128,
+            0.256,
+            0.512,
+            1.024,
+        ),
+    ):
         if inplace is None:
             self._inplace = True
             warnings.warn(
                 "'inplace' argument not specified. It is recommended to "
                 "specify True or False. In future releases, 'inplace' "
-                "will default to False.")
+                "will default to False."
+            )
         else:
             self._inplace = inplace
 
@@ -563,13 +612,15 @@ def __init__(self, handler_registry, *,
             raise EventModelValueError(
                 "The parameters `include` and `exclude` are mutually "
                 "incompatible. At least one must be left as the default, "
-                "None.")
+                "None."
+            )
         try:
             self._coercion_func = _coercion_registry[coerce]
         except KeyError:
             raise EventModelKeyError(
                 f"The option coerce={coerce!r} was given to event_model.Filler. "
-                f"The valid options are {set(_coercion_registry)}.")
+                f"The valid options are {set(_coercion_registry)}."
+            )
         self._coerce = coerce
 
         # See comments on coerision functions above for the use of
@@ -584,12 +635,16 @@ def __init__(self, handler_registry, *,
         if include is not None:
             warnings.warn(
                 "In a future release of event-model, the argument `include` "
-                "will be removed from Filler.", DeprecationWarning)
+                "will be removed from Filler.",
+                DeprecationWarning,
+            )
         self.include = include
         if exclude is not None:
             warnings.warn(
                 "In a future release of event-model, the argument `exclude` "
-                "will be removed from Filler.", DeprecationWarning)
+                "will be removed from Filler.",
+                DeprecationWarning,
+            )
         self.exclude = exclude
         self.root_map = root_map or {}
         if handler_cache is None:
@@ -617,19 +672,19 @@ def __init__(self, handler_registry, *,
 
     def __eq__(self, other):
         return (
-            type(self) is type(other) and
-            self.inplace == other.inplace and
-            self._coerce == other._coerce and
-            self.include == other.include and
-            self.exclude == other.exclude and
-            self.root_map == other.root_map and
-            type(self._handler_cache) is type(other._handler_cache) and
-            type(self._resource_cache) is type(other._resource_cache) and
-            type(self._datum_cache) is type(other._datum_cache) and
-            type(self._descriptor_cache) is type(other._descriptor_cache) and
-            type(self._stream_resource_cache) is type(other._stream_resource_cache) and
-            type(self._stream_datum_cache) is type(other._stream_datum_cache) and
-            self.retry_intervals == other.retry_intervals
+            type(self) is type(other)
+            and self.inplace == other.inplace
+            and self._coerce == other._coerce
+            and self.include == other.include
+            and self.exclude == other.exclude
+            and self.root_map == other.root_map
+            and type(self._handler_cache) is type(other._handler_cache)
+            and type(self._resource_cache) is type(other._resource_cache)
+            and type(self._datum_cache) is type(other._datum_cache)
+            and type(self._descriptor_cache) is type(other._descriptor_cache)
+            and type(self._stream_resource_cache) is type(other._stream_resource_cache)
+            and type(self._stream_datum_cache) is type(other._stream_datum_cache)
+            and self.retry_intervals == other.retry_intervals
         )
 
     def __getstate__(self):
@@ -646,11 +701,12 @@ def __getstate__(self):
             descriptor_cache=self._descriptor_cache,
             stream_resource_cache=self._stream_resource_cache,
             stream_datum_cache=self._stream_datum_cache,
-            retry_intervals=self.retry_intervals)
+            retry_intervals=self.retry_intervals,
+        )
 
     def __setstate__(self, d):
-        self._inplace = d['inplace']
-        self._coerce = d['coercion_func']
+        self._inplace = d["inplace"]
+        self._coerce = d["coercion_func"]
 
         # See comments on coerision functions above for the use of
         # _current_state, which is passed to coercion functions' `filler_state`
@@ -658,19 +714,19 @@ def __setstate__(self, d):
         self._current_state = threading.local()
         self._unpatched_handler_registry = {}
         self._handler_registry = {}
-        for spec, handler_class in d['handler_registry'].items():
+        for spec, handler_class in d["handler_registry"].items():
             self.register_handler(spec, handler_class)
         self.handler_registry = HandlerRegistryView(self._handler_registry)
-        self.include = d['include']
-        self.exclude = d['exclude']
-        self.root_map = d['root_map']
-        self._handler_cache = d['handler_cache']
-        self._resource_cache = d['resource_cache']
-        self._datum_cache = d['datum_cache']
-        self._descriptor_cache = d['descriptor_cache']
-        self._stream_resource_cache = d['stream_resource_cache']
-        self._stream_datum_cache = d['stream_datum_cache']
-        retry_intervals = d['retry_intervals']
+        self.include = d["include"]
+        self.exclude = d["exclude"]
+        self.root_map = d["root_map"]
+        self._handler_cache = d["handler_cache"]
+        self._resource_cache = d["resource_cache"]
+        self._datum_cache = d["datum_cache"]
+        self._descriptor_cache = d["descriptor_cache"]
+        self._stream_resource_cache = d["stream_resource_cache"]
+        self._stream_datum_cache = d["stream_datum_cache"]
+        retry_intervals = d["retry_intervals"]
         if retry_intervals is None:
             retry_intervals = []
         self._retry_intervals = retry_intervals
@@ -715,12 +771,21 @@ def get_default_stream_resource_cache():
     def inplace(self):
         return self._inplace
 
-    def clone(self, handler_registry=None, *,
-              root_map=None, coerce=None,
-              handler_cache=None, resource_cache=None, datum_cache=None,
-              descriptor_cache=None, stream_resource_cache=None,
-              stream_datum_cache=None, inplace=None,
-              retry_intervals=None):
+    def clone(
+        self,
+        handler_registry=None,
+        *,
+        root_map=None,
+        coerce=None,
+        handler_cache=None,
+        resource_cache=None,
+        datum_cache=None,
+        descriptor_cache=None,
+        stream_resource_cache=None,
+        stream_datum_cache=None,
+        inplace=None,
+        retry_intervals=None,
+    ):
         """
         Create a new Filler instance from this one.
 
@@ -740,16 +805,19 @@ def clone(self, handler_registry=None, *,
             inplace = self.inplace
         if retry_intervals is None:
             retry_intervals = self.retry_intervals
-        return Filler(handler_registry, root_map=root_map,
-                      coerce=coerce,
-                      handler_cache=handler_cache,
-                      resource_cache=resource_cache,
-                      datum_cache=datum_cache,
-                      descriptor_cache=descriptor_cache,
-                      stream_resource_cache=stream_resource_cache,
-                      stream_datum_cache=stream_datum_cache,
-                      inplace=inplace,
-                      retry_intervals=retry_intervals)
+        return Filler(
+            handler_registry,
+            root_map=root_map,
+            coerce=coerce,
+            handler_cache=handler_cache,
+            resource_cache=resource_cache,
+            datum_cache=datum_cache,
+            descriptor_cache=descriptor_cache,
+            stream_resource_cache=stream_resource_cache,
+            stream_datum_cache=stream_datum_cache,
+            inplace=inplace,
+            retry_intervals=retry_intervals,
+        )
 
     def register_handler(self, spec, handler, overwrite=False):
         """
@@ -777,15 +845,15 @@ def register_handler(self, spec, handler, overwrite=False):
                 f"There is already a handler registered for the spec {spec!r}. "
                 f"Use overwrite=True to deregister the original.\n"
                 f"Original: {original}\n"
-                f"New: {handler}")
+                f"New: {handler}"
+            )
 
         self.deregister_handler(spec)
         # Keep a raw copy, unused above for identifying redundant registration.
         self._unpatched_handler_registry[spec] = handler
         # Let the 'coerce' argument to Filler.__init__ modify the handler if it
         # wants to.
-        self._handler_registry[spec] = self._coercion_func(
-            handler, self._current_state)
+        self._handler_registry[spec] = self._coercion_func(handler, self._current_state)
 
     def deregister_handler(self, spec):
         """
@@ -816,7 +884,7 @@ def deregister_handler(self, spec):
     def resource(self, doc):
         # Defer creating the handler instance until we actually need it, when
         # we fill the first Event field that requires this Resource.
-        self._resource_cache[doc['uid']] = doc
+        self._resource_cache[doc["uid"]] = doc
         return doc
 
     # Handlers operate document-wise, so we'll explode pages into individual
@@ -829,7 +897,7 @@ def datum_page(self, doc):
         return doc
 
     def datum(self, doc):
-        self._datum_cache[doc['datum_id']] = doc
+        self._datum_cache[doc["datum_id"]] = doc
         return doc
 
     def stream_resource(self, doc):
@@ -844,28 +912,29 @@ def event_page(self, doc):
         # efficient than unpacking the page in to Events, filling them, and the
         # re-packing a new page. But that seems tricky in general since the
         # page may be implemented as a DataFrame or dict, etc.
-        filled_doc = self.fill_event_page(doc, include=self.include,
-                                          exclude=self.exclude)
+        filled_doc = self.fill_event_page(
+            doc, include=self.include, exclude=self.exclude
+        )
         return filled_doc
 
     def event(self, doc):
-        filled_doc = self.fill_event(doc, include=self.include,
-                                     exclude=self.exclude)
+        filled_doc = self.fill_event(doc, include=self.include, exclude=self.exclude)
         return filled_doc
 
     def fill_event_page(self, doc, include=None, exclude=None, inplace=None):
         filled_events = []
         for event_doc in unpack_event_page(doc):
-            filled_events.append(self.fill_event(event_doc,
-                                                 include=include,
-                                                 exclude=exclude,
-                                                 inplace=True))
+            filled_events.append(
+                self.fill_event(
+                    event_doc, include=include, exclude=exclude, inplace=True
+                )
+            )
         filled_doc = pack_event_page(*filled_events)
         if inplace is None:
             inplace = self._inplace
         if inplace:
-            doc['data'] = filled_doc['data']
-            doc['filled'] = filled_doc['filled']
+            doc["data"] = filled_doc["data"]
+            doc["filled"] = filled_doc["filled"]
             return doc
         else:
             return filled_doc
@@ -884,43 +953,51 @@ def get_handler(self, resource):
         """
         if self._closed:
             raise EventModelRuntimeError(
-                "This Filler has been closed and is no longer usable.")
+                "This Filler has been closed and is no longer usable."
+            )
         try:
-            handler_class = self.handler_registry[resource['spec']]
+            handler_class = self.handler_registry[resource["spec"]]
         except KeyError as err:
             raise UndefinedAssetSpecification(
                 f"Resource document with uid {resource['uid']} "
                 f"refers to spec {resource['spec']!r} which is "
                 f"not defined in the Filler's "
-                f"handler registry.") from err
+                f"handler registry."
+            ) from err
         # Apply root_map.
-        resource_path = resource['resource_path']
-        original_root = resource.get('root', '')
+        resource_path = resource["resource_path"]
+        original_root = resource.get("root", "")
         root = self.root_map.get(original_root, original_root)
         if root:
             resource_path = os.path.join(root, resource_path)
-        msg = (f"Error instantiating handler "
-               f"class {handler_class} "
-               f"with Resource document {resource}. ")
+        msg = (
+            f"Error instantiating handler "
+            f"class {handler_class} "
+            f"with Resource document {resource}. "
+        )
         if root != original_root:
-            msg += (f"Its 'root' field was "
-                    f"mapped from {original_root} to {root} by root_map.")
+            msg += (
+                f"Its 'root' field was "
+                f"mapped from {original_root} to {root} by root_map."
+            )
         else:
-            msg += (f"Its 'root' field {original_root} was "
-                    f"*not* modified by root_map.")
+            msg += (
+                f"Its 'root' field {original_root} was " f"*not* modified by root_map."
+            )
         error_to_raise = EventModelError(msg)
         handler = _attempt_with_retries(
             func=handler_class,
             args=(resource_path,),
-            kwargs=resource['resource_kwargs'],
+            kwargs=resource["resource_kwargs"],
             intervals=[0] + self.retry_intervals,
             error_to_catch=IOError,
-            error_to_raise=error_to_raise)
+            error_to_raise=error_to_raise,
+        )
         return handler
 
     def _get_handler_maybe_cached(self, resource):
         "Get a cached handler for this resource or make one and cache it."
-        key = (resource['uid'], resource['spec'])
+        key = (resource["uid"], resource["spec"])
         try:
             handler = self._handler_cache[key]
         except KeyError:
@@ -935,17 +1012,17 @@ def fill_event(self, doc, include=None, exclude=None, inplace=None):
             filled_doc = doc
         else:
             filled_doc = copy.deepcopy(doc)
-        descriptor = self._descriptor_cache[doc['descriptor']]
+        descriptor = self._descriptor_cache[doc["descriptor"]]
         from_datakeys = False
         self._current_state.descriptor = descriptor
         try:
-            needs_filling = {key for key, val in doc['filled'].items()
-                             if val is False}
+            needs_filling = {key for key, val in doc["filled"].items() if val is False}
         except KeyError:
             # This document is not telling us which, if any, keys are filled.
             # Infer that none of the external data is filled.
-            needs_filling = {key for key, val in descriptor['data_keys'].items()
-                             if 'external' in val}
+            needs_filling = {
+                key for key, val in descriptor["data_keys"].items() if "external" in val
+            }
             from_datakeys = True
         for key in needs_filling:
             self._current_state.key = key
@@ -954,7 +1031,7 @@ def fill_event(self, doc, include=None, exclude=None, inplace=None):
             if include is not None and key not in include:
                 continue
             try:
-                datum_id = doc['data'][key]
+                datum_id = doc["data"][key]
             except KeyError as err:
                 if from_datakeys:
                     raise MismatchedDataKeys(
@@ -965,7 +1042,8 @@ def fill_event(self, doc, include=None, exclude=None, inplace=None):
                         "must equal descriptor['data_keys'].keys(). "
                         f"event['data'].keys(): {doc['data'].keys()}, "
                         "descriptor['data_keys'].keys(): "
-                        f"{descriptor['data_keys'].keys()}") from err
+                        f"{descriptor['data_keys'].keys()}"
+                    ) from err
                 else:
                     raise MismatchedDataKeys(
                         "The documents are not valid.  Either because they "
@@ -975,7 +1053,8 @@ def fill_event(self, doc, include=None, exclude=None, inplace=None):
                         "must be a subset of event['data'].keys(). "
                         f"event['data'].keys(): {doc['data'].keys()}, "
                         "event['filled'].keys(): "
-                        f"{doc['filled'].keys()}") from err
+                        f"{doc['filled'].keys()}"
+                    ) from err
             # Look up the cached Datum doc.
             try:
                 datum_doc = self._datum_cache[datum_id]
@@ -983,8 +1062,9 @@ def fill_event(self, doc, include=None, exclude=None, inplace=None):
                 raise UnresolvableForeignKeyError(
                     datum_id,
                     f"Event with uid {doc['uid']} refers to unknown Datum "
-                    f"datum_id {datum_id}") from err
-            resource_uid = datum_doc['resource']
+                    f"datum_id {datum_id}",
+                ) from err
+            resource_uid = datum_doc["resource"]
             # Look up the cached Resource.
             try:
                 resource = self._resource_cache[resource_uid]
@@ -992,24 +1072,27 @@ def fill_event(self, doc, include=None, exclude=None, inplace=None):
                 raise UnresolvableForeignKeyError(
                     resource_uid,
                     f"Datum with id {datum_id} refers to unknown Resource "
-                    f"uid {resource_uid}") from err
+                    f"uid {resource_uid}",
+                ) from err
             self._current_state.resource = resource
             self._current_state.datum = datum_doc
             handler = self._get_handler_maybe_cached(resource)
             error_to_raise = DataNotAccessible(
-                    f"Filler was unable to load the data referenced by "
-                    f"the Datum document {datum_doc} and the Resource "
-                    f"document {resource}.")
+                f"Filler was unable to load the data referenced by "
+                f"the Datum document {datum_doc} and the Resource "
+                f"document {resource}."
+            )
             payload = _attempt_with_retries(
                 func=handler,
                 args=(),
-                kwargs=datum_doc['datum_kwargs'],
+                kwargs=datum_doc["datum_kwargs"],
                 intervals=[0] + self.retry_intervals,
                 error_to_catch=IOError,
-                error_to_raise=error_to_raise)
+                error_to_raise=error_to_raise,
+            )
             # Here we are intentionally modifying doc in place.
-            filled_doc['data'][key] = payload
-            filled_doc['filled'][key] = datum_id
+            filled_doc["data"][key] = payload
+            filled_doc["filled"][key] = datum_id
         self._current_state.key = None
         self._current_state.descriptor = None
         self._current_state.resource = None
@@ -1017,7 +1100,7 @@ def fill_event(self, doc, include=None, exclude=None, inplace=None):
         return filled_doc
 
     def descriptor(self, doc):
-        self._descriptor_cache[doc['uid']] = doc
+        self._descriptor_cache[doc["uid"]] = doc
         return doc
 
     def __enter__(self):
@@ -1067,13 +1150,14 @@ def __exit__(self, *exc_details):
     def __call__(self, name, doc, validate=False):
         if self._closed:
             raise EventModelRuntimeError(
-                "This Filler has been closed and is no longer usable.")
+                "This Filler has been closed and is no longer usable."
+            )
         return super().__call__(name, doc, validate)
 
 
-def _attempt_with_retries(func, args, kwargs,
-                          intervals,
-                          error_to_catch, error_to_raise):
+def _attempt_with_retries(
+    func, args, kwargs, intervals, error_to_catch, error_to_raise
+):
     """
     Return func(*args, **kwargs), using a retry loop.
 
@@ -1112,33 +1196,35 @@ class NoFiller(Filler):
     a delayed computation, but we want to make sure in advance that we have all
     the information that we will need when that computation occurs.
     """
+
     def __init__(self, *args, **kwargs):
         # Do not make Filler make copies because we are not going to alter the
         # documents anyway.
-        kwargs.setdefault('inplace', True)
+        kwargs.setdefault("inplace", True)
         super().__init__(*args, **kwargs)
 
     def fill_event_page(self, doc, include=None, exclude=None):
         filled_events = []
         for event_doc in unpack_event_page(doc):
-            filled_events.append(self.fill_event(event_doc,
-                                                 include=include,
-                                                 exclude=exclude,
-                                                 inplace=True))
+            filled_events.append(
+                self.fill_event(
+                    event_doc, include=include, exclude=exclude, inplace=True
+                )
+            )
         filled_doc = pack_event_page(*filled_events)
         return filled_doc
 
     def fill_event(self, doc, include=None, exclude=None, inplace=None):
-        descriptor = self._descriptor_cache[doc['descriptor']]
+        descriptor = self._descriptor_cache[doc["descriptor"]]
         from_datakeys = False
         try:
-            needs_filling = {key for key, val in doc['filled'].items()
-                             if val is False}
+            needs_filling = {key for key, val in doc["filled"].items() if val is False}
         except KeyError:
             # This document is not telling us which, if any, keys are filled.
             # Infer that none of the external data is filled.
-            needs_filling = {key for key, val in descriptor['data_keys'].items()
-                             if 'external' in val}
+            needs_filling = {
+                key for key, val in descriptor["data_keys"].items() if "external" in val
+            }
             from_datakeys = True
         for key in needs_filling:
             if exclude is not None and key in exclude:
@@ -1146,7 +1232,7 @@ def fill_event(self, doc, include=None, exclude=None, inplace=None):
             if include is not None and key not in include:
                 continue
             try:
-                datum_id = doc['data'][key]
+                datum_id = doc["data"][key]
             except KeyError as err:
                 if from_datakeys:
                     raise MismatchedDataKeys(
@@ -1157,7 +1243,8 @@ def fill_event(self, doc, include=None, exclude=None, inplace=None):
                         "must equal descriptor['data_keys'].keys(). "
                         f"event['data'].keys(): {doc['data'].keys()}, "
                         "descriptor['data_keys'].keys(): "
-                        f"{descriptor['data_keys'].keys()}") from err
+                        f"{descriptor['data_keys'].keys()}"
+                    ) from err
                 else:
                     raise MismatchedDataKeys(
                         "The documents are not valid.  Either because they "
@@ -1167,7 +1254,8 @@ def fill_event(self, doc, include=None, exclude=None, inplace=None):
                         "must be a subset of event['data'].keys(). "
                         f"event['data'].keys(): {doc['data'].keys()}, "
                         "event['filled'].keys(): "
-                        f"{doc['filled'].keys()}") from err
+                        f"{doc['filled'].keys()}"
+                    ) from err
             # Look up the cached Datum doc.
             try:
                 datum_doc = self._datum_cache[datum_id]
@@ -1175,10 +1263,11 @@ def fill_event(self, doc, include=None, exclude=None, inplace=None):
                 err_with_key = UnresolvableForeignKeyError(
                     datum_id,
                     f"Event with uid {doc['uid']} refers to unknown Datum "
-                    f"datum_id {datum_id}")
+                    f"datum_id {datum_id}",
+                )
                 err_with_key.key = datum_id
                 raise err_with_key from err
-            resource_uid = datum_doc['resource']
+            resource_uid = datum_doc["resource"]
             # Look up the cached Resource.
             try:
                 self._resource_cache[resource_uid]
@@ -1186,7 +1275,8 @@ def fill_event(self, doc, include=None, exclude=None, inplace=None):
                 raise UnresolvableForeignKeyError(
                     datum_id,
                     f"Datum with id {datum_id} refers to unknown Resource "
-                    f"uid {resource_uid}") from err
+                    f"uid {resource_uid}",
+                ) from err
         return doc
 
 
@@ -1199,7 +1289,8 @@ def fill_event(self, doc, include=None, exclude=None, inplace=None):
     "RunRouter passes in the document, causing the "
     "callback to receive it twice and potentially raise "
     "an error. Update the factory function. In a future "
-    "release this warning will become an error.")
+    "release this warning will become an error."
+)
 
 
 class RunRouter(DocumentRouter):
@@ -1277,8 +1368,16 @@ class RunRouter(DocumentRouter):
         if set to True, fill everything and `raise
         ``UndefinedAssetSpecification`` if some unknown spec is encountered.
     """
-    def __init__(self, factories, handler_registry=None, *,
-                 root_map=None, filler_class=Filler, fill_or_fail=False):
+
+    def __init__(
+        self,
+        factories,
+        handler_registry=None,
+        *,
+        root_map=None,
+        filler_class=Filler,
+        fill_or_fail=False,
+    ):
         self.factories = factories
         self.handler_registry = handler_registry or {}
         self.filler_class = filler_class
@@ -1328,12 +1427,14 @@ def __init__(self, factories, handler_registry=None, *,
         self._fillers = {}
 
     def __repr__(self):
-        return ("RunRouter([\n" +
-                "\n".join(f"    {factory}" for factory in self.factories) +
-                "])")
+        return (
+            "RunRouter([\n"
+            + "\n".join(f"    {factory}" for factory in self.factories)
+            + "])"
+        )
 
     def start(self, start_doc):
-        uid = start_doc['uid']
+        uid = start_doc["uid"]
         # If we get the same uid twice, weird things will happen, so check for
         # that and give a nice error message.
         if uid in self._start_to_start_doc:
@@ -1347,30 +1448,33 @@ def start(self, start_doc):
                     "RunRouter received two 'start' documents with different "
                     "contents but the same uid:\n"
                     "First: {self._start_to_start_doc[uid]!r}\n"
-                    "Second: {start_doc!r}")
+                    "Second: {start_doc!r}"
+                )
         self._start_to_start_doc[uid] = start_doc
-        filler = self.filler_class(self.handler_registry,
-                                   root_map=self.root_map,
-                                   inplace=False)
+        filler = self.filler_class(
+            self.handler_registry, root_map=self.root_map, inplace=False
+        )
         self._fillers[uid] = filler
         # No need to pass the document to filler
         # because Fillers do nothing with 'start'.
         for factory in self.factories:
-            callbacks, subfactories = factory('start', start_doc)
+            callbacks, subfactories = factory("start", start_doc)
             for callback in callbacks:
                 try:
-                    callback('start', start_doc)
+                    callback("start", start_doc)
                 except Exception as err:
                     warnings.warn(
                         DOCS_PASSED_IN_1_14_0_WARNING.format(
-                            callback=callback, name='start', err=err))
+                            callback=callback, name="start", err=err
+                        )
+                    )
                     raise err
             self._factory_cbs_by_start[uid].extend(callbacks)
             self._subfactories[uid].extend(subfactories)
 
     def descriptor(self, descriptor_doc):
-        descriptor_uid = descriptor_doc['uid']
-        start_uid = descriptor_doc['run_start']
+        descriptor_uid = descriptor_doc["uid"]
+        start_uid = descriptor_doc["run_start"]
 
         # Keep track of the RunStart UID -> [EventDescriptor UIDs] mapping for
         # purposes of cleanup in stop().
@@ -1384,31 +1488,35 @@ def descriptor(self, descriptor_doc):
         factory_cbs = self._factory_cbs_by_start[start_uid]
         self._factory_cbs_by_descriptor[descriptor_uid].extend(factory_cbs)
         for callback in factory_cbs:
-            callback('descriptor', descriptor_doc)
+            callback("descriptor", descriptor_doc)
         # Let all the subfactories add any relevant callbacks.
         for subfactory in self._subfactories[start_uid]:
-            callbacks = subfactory('descriptor', descriptor_doc)
+            callbacks = subfactory("descriptor", descriptor_doc)
             self._subfactory_cbs_by_start[start_uid].extend(callbacks)
             self._subfactory_cbs_by_descriptor[descriptor_uid].extend(callbacks)
             for callback in callbacks:
                 try:
                     start_doc = self._start_to_start_doc[start_uid]
-                    callback('start', start_doc)
+                    callback("start", start_doc)
                 except Exception as err:
                     warnings.warn(
                         DOCS_PASSED_IN_1_14_0_WARNING.format(
-                            callback=callback, name='start', err=err))
+                            callback=callback, name="start", err=err
+                        )
+                    )
                     raise err
                 try:
-                    callback('descriptor', descriptor_doc)
+                    callback("descriptor", descriptor_doc)
                 except Exception as err:
                     warnings.warn(
                         DOCS_PASSED_IN_1_14_0_WARNING.format(
-                            callback=callback, name='descriptor', err=err))
+                            callback=callback, name="descriptor", err=err
+                        )
+                    )
                     raise err
 
     def event_page(self, doc):
-        descriptor_uid = doc['descriptor']
+        descriptor_uid = doc["descriptor"]
         start_uid = self._descriptor_to_start[descriptor_uid]
         try:
             doc = self._fillers[start_uid].event_page(doc)
@@ -1416,19 +1524,20 @@ def event_page(self, doc):
             if self.fill_or_fail:
                 raise
         for callback in self._factory_cbs_by_descriptor[descriptor_uid]:
-            callback('event_page', doc)
+            callback("event_page", doc)
         for callback in self._subfactory_cbs_by_descriptor[descriptor_uid]:
-            callback('event_page', doc)
+            callback("event_page", doc)
 
     def datum_page(self, doc):
-        resource_uid = doc['resource']
+        resource_uid = doc["resource"]
         try:
             start_uid = self._resources[resource_uid]
         except KeyError:
             if resource_uid not in self._unlabeled_resources:
                 raise UnresolvableForeignKeyError(
                     resource_uid,
-                    f"DatumPage refers to unknown Resource uid {resource_uid}")
+                    f"DatumPage refers to unknown Resource uid {resource_uid}",
+                )
             # Old Resources do not have a reference to a RunStart document,
             # so in turn we cannot immediately tell which run these datum
             # documents belong to.
@@ -1437,18 +1546,18 @@ def datum_page(self, doc):
             # expected to be an increasingly rare case.
             for callbacks in self._factory_cbs_by_start.values():
                 for callback in callbacks:
-                    callback('datum_page', doc)
+                    callback("datum_page", doc)
             for callbacks in self._subfactory_cbs_by_start.values():
                 for callback in callbacks:
-                    callback('datum_page', doc)
+                    callback("datum_page", doc)
             for filler in self._fillers.values():
                 filler.datum_page(doc)
         else:
             self._fillers[start_uid].datum_page(doc)
             for callback in self._factory_cbs_by_start[start_uid]:
-                callback('datum_page', doc)
+                callback("datum_page", doc)
             for callback in self._subfactory_cbs_by_start[start_uid]:
-                callback('datum_page', doc)
+                callback("datum_page", doc)
 
     def stream_datum(self, doc):
         resource_uid = doc["stream_resource"]
@@ -1461,28 +1570,28 @@ def stream_datum(self, doc):
 
     def resource(self, doc):
         try:
-            start_uid = doc['run_start']
+            start_uid = doc["run_start"]
         except KeyError:
             # Old Resources do not have a reference to a RunStart document.
             # Fan them out to every run currently flowing through RunRouter. If
             # they are not applicable they will do no harm, and this is
             # expected to be an increasingly rare case.
-            self._unlabeled_resources.append(doc['uid'])
+            self._unlabeled_resources.append(doc["uid"])
             for callbacks in self._factory_cbs_by_start.values():
                 for callback in callbacks:
-                    callback('resource', doc)
+                    callback("resource", doc)
             for callbacks in self._subfactory_cbs_by_start.values():
                 for callback in callbacks:
-                    callback('resource', doc)
+                    callback("resource", doc)
             for filler in self._fillers.values():
                 filler.resource(doc)
         else:
             self._fillers[start_uid].resource(doc)
-            self._resources[doc['uid']] = doc['run_start']
+            self._resources[doc["uid"]] = doc["run_start"]
             for callback in self._factory_cbs_by_start[start_uid]:
-                callback('resource', doc)
+                callback("resource", doc)
             for callback in self._subfactory_cbs_by_start[start_uid]:
-                callback('resource', doc)
+                callback("resource", doc)
 
     def stream_resource(self, doc):
         start_uid = doc["run_start"]  # No need for Try
@@ -1494,11 +1603,11 @@ def stream_resource(self, doc):
             callback("stream_resource", doc)
 
     def stop(self, doc):
-        start_uid = doc['run_start']
+        start_uid = doc["run_start"]
         for callback in self._factory_cbs_by_start[start_uid]:
-            callback('stop', doc)
+            callback("stop", doc)
         for callback in self._subfactory_cbs_by_start[start_uid]:
-            callback('stop', doc)
+            callback("stop", doc)
         # Clean up references.
         self._fillers.pop(start_uid, None)
         self._subfactories.pop(start_uid, None)
@@ -1515,6 +1624,7 @@ def stop(self, doc):
 class EventModelError(Exception):
     ...
 
+
 # Here we define subclasses of all of the built-in Python exception types (as
 # needed, not a comprehensive list) so that all errors raised *directly* by
 # event_model also inhereit from EventModelError as well as the appropriate
@@ -1545,21 +1655,25 @@ class EventModelValidationError(EventModelError):
 
 class UnfilledData(EventModelError):
     """raised when unfilled data is found"""
+
     ...
 
 
 class UndefinedAssetSpecification(EventModelKeyError):
     """raised when a resource spec is missing from the handler registry"""
+
     ...
 
 
 class DataNotAccessible(EventModelError, IOError):
     """raised when attempts to load data referenced by Datum document fail"""
+
     ...
 
 
 class UnresolvableForeignKeyError(EventModelValueError):
     """when we see a foreign before we see the thing to which it refers"""
+
     def __init__(self, key, message):
         self.key = key
         self.message = message
@@ -1567,11 +1681,13 @@ def __init__(self, key, message):
 
 class DuplicateHandler(EventModelRuntimeError):
     """raised when a handler is already registered for a given spec"""
+
     ...
 
 
 class InvalidData(EventModelError):
     """raised when the data is invalid"""
+
     ...
 
 
@@ -1581,66 +1697,82 @@ class MismatchedDataKeys(InvalidData):
     event['data'].keys(), descriptor['data_keys'].keys(),
     event['timestamp'].keys(), event['filled'].keys()
     """
+
     ...
 
 
-SCHEMA_PATH = 'schemas'
-SCHEMA_NAMES = {DocumentNames.start: 'schemas/run_start.json',
-                DocumentNames.stop: 'schemas/run_stop.json',
-                DocumentNames.event: 'schemas/event.json',
-                DocumentNames.event_page: 'schemas/event_page.json',
-                DocumentNames.descriptor: 'schemas/event_descriptor.json',
-                DocumentNames.datum: 'schemas/datum.json',
-                DocumentNames.datum_page: 'schemas/datum_page.json',
-                DocumentNames.resource: 'schemas/resource.json',
-                DocumentNames.stream_datum: 'schemas/stream_datum.json',
-                DocumentNames.stream_resource: 'schemas/stream_resource.json',
-                # DEPRECATED:
-                DocumentNames.bulk_events: 'schemas/bulk_events.json',
-                DocumentNames.bulk_datum: 'schemas/bulk_datum.json'}
+SCHEMA_PATH = "schemas"
+SCHEMA_NAMES = {
+    DocumentNames.start: "schemas/run_start.json",
+    DocumentNames.stop: "schemas/run_stop.json",
+    DocumentNames.event: "schemas/event.json",
+    DocumentNames.event_page: "schemas/event_page.json",
+    DocumentNames.descriptor: "schemas/event_descriptor.json",
+    DocumentNames.datum: "schemas/datum.json",
+    DocumentNames.datum_page: "schemas/datum_page.json",
+    DocumentNames.resource: "schemas/resource.json",
+    DocumentNames.stream_datum: "schemas/stream_datum.json",
+    DocumentNames.stream_resource: "schemas/stream_resource.json",
+    # DEPRECATED:
+    DocumentNames.bulk_events: "schemas/bulk_events.json",
+    DocumentNames.bulk_datum: "schemas/bulk_datum.json",
+}
 schemas = {}
 for name, filename in SCHEMA_NAMES.items():
-    with open(rs_fn('event_model', filename)) as fin:
+    with open(rs_fn("event_model", filename)) as fin:
         schemas[name] = json.load(fin)
 
 # We pin jsonschema >=3.0.0 in requirements.txt but due to pip's dependency
 # resolution it is easy to end up with an environment where that pin is not
 # respected. Thus, we maintain best-effort support for 2.x.
 if version.parse(metadata("jsonschema")["version"]) >= version.parse("3.0.0"):
+
     def _is_array(checker, instance):
         return (
-            jsonschema.validators.Draft7Validator.TYPE_CHECKER.is_type(instance, 'array') or
-            isinstance(instance, tuple) or
-            hasattr(instance, "__array__")
+            jsonschema.validators.Draft7Validator.TYPE_CHECKER.is_type(
+                instance, "array"
+            )
+            or isinstance(instance, tuple)
+            or hasattr(instance, "__array__")
         )
 
-    _array_type_checker = jsonschema.validators.Draft7Validator.TYPE_CHECKER.redefine('array', _is_array)
+    _array_type_checker = jsonschema.validators.Draft7Validator.TYPE_CHECKER.redefine(
+        "array", _is_array
+    )
 
     _Validator = jsonschema.validators.extend(
-        jsonschema.validators.Draft7Validator,
-        type_checker=_array_type_checker)
+        jsonschema.validators.Draft7Validator, type_checker=_array_type_checker
+    )
 
-    schema_validators = {name: _Validator(schema=schema) for name, schema in schemas.items()}
+    schema_validators = {
+        name: _Validator(schema=schema) for name, schema in schemas.items()
+    }
 else:
     # Make objects that mock the one method on the jsonschema 3.x
     # Draft7Validator API that we need.
     schema_validators = {
         name: types.SimpleNamespace(
-            validate=partial(jsonschema.validate, schema=schema, types={'array': (list, tuple)}))
+            validate=partial(
+                jsonschema.validate, schema=schema, types={"array": (list, tuple)}
+            )
+        )
         for name, schema in schemas.items()
     }
 
 
-__version__ = get_versions()['version']
+__version__ = get_versions()["version"]
 del get_versions
 
 
 @dataclass
 class ComposeRunBundle:
-    """Extensible compose run bundle. This maintains backward compatibility by unpacking into a basic
-    run bundle (start, compose_descriptor, compose_resource, stop). Further extensions are optional and
-    require keyword referencing (i.e. compose_stream_resource).
+    """Extensible compose run bundle. This maintains backward compatibility
+    by unpacking into a basic run bundle
+    (start, compose_descriptor, compose_resource, stop).
+    Further extensions are optional and require keyword referencing
+    (i.e. compose_stream_resource).
     """
+
     start_doc: dict
     compose_descriptor: callable
     compose_resource: callable
@@ -1648,55 +1780,80 @@ class ComposeRunBundle:
     compose_stream_resource: Optional[callable] = None
 
     def __iter__(self):
-        return iter((self.start_doc, self.compose_descriptor, self.compose_resource, self.compose_stop))
+        return iter(
+            (
+                self.start_doc,
+                self.compose_descriptor,
+                self.compose_resource,
+                self.compose_stop,
+            )
+        )
 
 
-ComposeDescriptorBundle = namedtuple('ComposeDescriptorBundle',
-                                     'descriptor_doc compose_event compose_event_page')
-ComposeResourceBundle = namedtuple('ComposeResourceBundle',
-                                   'resource_doc compose_datum compose_datum_page')
-ComposeStreamResourceBundle = namedtuple('ComposeStreamResourceBundle',
-                                         'stream_resource_doc compose_stream_data')
+ComposeDescriptorBundle = namedtuple(
+    "ComposeDescriptorBundle", "descriptor_doc compose_event compose_event_page"
+)
+ComposeResourceBundle = namedtuple(
+    "ComposeResourceBundle", "resource_doc compose_datum compose_datum_page"
+)
+ComposeStreamResourceBundle = namedtuple(
+    "ComposeStreamResourceBundle", "stream_resource_doc compose_stream_data"
+)
 
 
 def compose_datum(*, resource, counter, datum_kwargs, validate=True):
-    resource_uid = resource['uid']
-    doc = {'resource': resource_uid,
-           'datum_kwargs': datum_kwargs,
-           'datum_id': '{}/{}'.format(resource_uid, next(counter))}
+    resource_uid = resource["uid"]
+    doc = {
+        "resource": resource_uid,
+        "datum_kwargs": datum_kwargs,
+        "datum_id": "{}/{}".format(resource_uid, next(counter)),
+    }
     if validate:
         schema_validators[DocumentNames.datum].validate(doc)
     return doc
 
 
 def compose_datum_page(*, resource, counter, datum_kwargs, validate=True):
-    resource_uid = resource['uid']
+    resource_uid = resource["uid"]
     any_column, *_ = datum_kwargs.values()
     N = len(any_column)
-    doc = {'resource': resource_uid,
-           'datum_kwargs': datum_kwargs,
-           'datum_id': ['{}/{}'.format(resource_uid, next(counter)) for _ in range(N)]}
+    doc = {
+        "resource": resource_uid,
+        "datum_kwargs": datum_kwargs,
+        "datum_id": ["{}/{}".format(resource_uid, next(counter)) for _ in range(N)],
+    }
     if validate:
         schema_validators[DocumentNames.datum_page].validate(doc)
     return doc
 
 
-default_path_semantics = {'posix': 'posix', 'nt': 'windows'}[os.name]
+default_path_semantics = {"posix": "posix", "nt": "windows"}[os.name]
 
 
-def compose_resource(*, spec, root, resource_path, resource_kwargs,
-                     path_semantics=default_path_semantics, start=None, uid=None, validate=True):
+def compose_resource(
+    *,
+    spec,
+    root,
+    resource_path,
+    resource_kwargs,
+    path_semantics=default_path_semantics,
+    start=None,
+    uid=None,
+    validate=True,
+):
     if uid is None:
         uid = str(uuid.uuid4())
     counter = itertools.count()
-    doc = {'uid': uid,
-           'spec': spec,
-           'root': root,
-           'resource_path': resource_path,
-           'resource_kwargs': resource_kwargs,
-           'path_semantics': path_semantics}
+    doc = {
+        "uid": uid,
+        "spec": spec,
+        "root": root,
+        "resource_path": resource_path,
+        "resource_kwargs": resource_kwargs,
+        "path_semantics": path_semantics,
+    }
     if start:
-        doc['run_start'] = start['uid']
+        doc["run_start"] = start["uid"]
 
     if validate:
         schema_validators[DocumentNames.resource].validate(doc)
@@ -1704,46 +1861,76 @@ def compose_resource(*, spec, root, resource_path, resource_kwargs,
     return ComposeResourceBundle(
         doc,
         partial(compose_datum, resource=doc, counter=counter),
-        partial(compose_datum_page, resource=doc, counter=counter))
+        partial(compose_datum_page, resource=doc, counter=counter),
+    )
 
 
-def compose_stream_datum(*, stream_resource, stream_name, counter, datum_kwargs,
-                         event_count=1, event_offset=0, validate=True):
-    resource_uid = stream_resource['uid']
+def compose_stream_datum(
+    *,
+    stream_resource,
+    stream_name,
+    counter,
+    datum_kwargs,
+    event_count=1,
+    event_offset=0,
+    validate=True,
+):
+    resource_uid = stream_resource["uid"]
     if stream_name not in stream_resource["stream_names"]:
-        raise EventModelKeyError("Attempt to create stream_datum with name not included in stream_resource")
+        raise EventModelKeyError(
+            "Attempt to create stream_datum with name not included in stream_resource"
+        )
     block_idx = next(counter)
-    doc = dict(stream_resource=resource_uid,
-               datum_kwargs=datum_kwargs,
-               uid=f"{resource_uid}/{stream_name}/{block_idx}",
-               stream_name=stream_name,
-               block_idx=block_idx,
-               event_count=event_count,
-               event_offset=event_offset,
-               )
+    doc = dict(
+        stream_resource=resource_uid,
+        datum_kwargs=datum_kwargs,
+        uid=f"{resource_uid}/{stream_name}/{block_idx}",
+        stream_name=stream_name,
+        block_idx=block_idx,
+        event_count=event_count,
+        event_offset=event_offset,
+    )
     if validate:
         schema_validators[DocumentNames.stream_datum].validate(doc)
     return doc
 
 
-def compose_stream_resource(*, spec, root, resource_path, resource_kwargs, stream_names, counters=(),
-                            path_semantics=default_path_semantics, start=None, uid=None, validate=True):
+def compose_stream_resource(
+    *,
+    spec,
+    root,
+    resource_path,
+    resource_kwargs,
+    stream_names,
+    counters=(),
+    path_semantics=default_path_semantics,
+    start=None,
+    uid=None,
+    validate=True,
+):
     if uid is None:
         uid = str(uuid.uuid4())
     if isinstance(stream_names, str):
-        stream_names = [stream_names, ]
+        stream_names = [
+            stream_names,
+        ]
     if len(counters) == 0:
         counters = [itertools.count() for _ in stream_names]
     elif len(counters) > len(stream_names):
-        raise ValueError(f"Insufficient number of counters {len(counters)} for stream names: {stream_names}")
-
-    doc = dict(uid=uid,
-               spec=spec,
-               root=root,
-               resource_path=resource_path,
-               resource_kwargs=resource_kwargs,
-               stream_names=stream_names,
-               path_semantics=path_semantics)
+        raise ValueError(
+            f"Insufficient number of counters {len(counters)} for "
+            f"stream names: {stream_names}"
+        )
+
+    doc = dict(
+        uid=uid,
+        spec=spec,
+        root=root,
+        resource_path=resource_path,
+        resource_kwargs=resource_kwargs,
+        stream_names=stream_names,
+        path_semantics=path_semantics,
+    )
     if start:
         doc["run_start"] = start["uid"]
 
@@ -1752,36 +1939,63 @@ def compose_stream_resource(*, spec, root, resource_path, resource_kwargs, strea
 
     return ComposeStreamResourceBundle(
         doc,
-        [partial(compose_stream_datum, stream_resource=doc, stream_name=stream_name, counter=counter) for
-         stream_name, counter in zip(stream_names, counters)]
+        [
+            partial(
+                compose_stream_datum,
+                stream_resource=doc,
+                stream_name=stream_name,
+                counter=counter,
+            )
+            for stream_name, counter in zip(stream_names, counters)
+        ],
     )
 
 
-def compose_stop(*, start, event_counter, poison_pill,
-                 exit_status='success', reason='',
-                 uid=None, time=None,
-                 validate=True):
+def compose_stop(
+    *,
+    start,
+    event_counter,
+    poison_pill,
+    exit_status="success",
+    reason="",
+    uid=None,
+    time=None,
+    validate=True,
+):
     if poison_pill:
-        raise EventModelError("Already composed a RunStop document for run "
-                              "{!r}.".format(start['uid']))
+        raise EventModelError(
+            "Already composed a RunStop document for run " "{!r}.".format(start["uid"])
+        )
     poison_pill.append(object())
     if uid is None:
         uid = str(uuid.uuid4())
     if time is None:
         time = ttime.time()
-    doc = {'uid': uid,
-           'time': time,
-           'run_start': start['uid'],
-           'exit_status': exit_status,
-           'reason': reason,
-           'num_events': {k: v - 1 for k, v in event_counter.items()}}
+    doc = {
+        "uid": uid,
+        "time": time,
+        "run_start": start["uid"],
+        "exit_status": exit_status,
+        "reason": reason,
+        "num_events": {k: v - 1 for k, v in event_counter.items()},
+    }
     if validate:
         schema_validators[DocumentNames.stop].validate(doc)
     return doc
 
 
-def compose_event_page(*, descriptor, event_counter, data, timestamps, seq_num,
-                       filled=None, uid=None, time=None, validate=True):
+def compose_event_page(
+    *,
+    descriptor,
+    event_counter,
+    data,
+    timestamps,
+    seq_num,
+    filled=None,
+    uid=None,
+    time=None,
+    validate=True,
+):
     N = len(seq_num)
     if uid is None:
         uid = [str(uuid.uuid4()) for _ in range(N)]
@@ -1789,68 +2003,98 @@ def compose_event_page(*, descriptor, event_counter, data, timestamps, seq_num,
         time = [ttime.time()] * N
     if filled is None:
         filled = {}
-    doc = {'uid': uid,
-           'time': time,
-           'data': data,
-           'timestamps': timestamps,
-           'seq_num': seq_num,
-           'filled': filled,
-           'descriptor': descriptor['uid']}
+    doc = {
+        "uid": uid,
+        "time": time,
+        "data": data,
+        "timestamps": timestamps,
+        "seq_num": seq_num,
+        "filled": filled,
+        "descriptor": descriptor["uid"],
+    }
     if validate:
         schema_validators[DocumentNames.event_page].validate(doc)
-        if not (descriptor['data_keys'].keys() == data.keys() == timestamps.keys()):
+        if not (descriptor["data_keys"].keys() == data.keys() == timestamps.keys()):
             raise EventModelValidationError(
                 "These sets of keys must match:\n"
                 "event['data'].keys(): {}\n"
                 "event['timestamps'].keys(): {}\n"
                 "descriptor['data_keys'].keys(): {}\n".format(
-                    data.keys(), timestamps.keys(), descriptor['data_keys'].keys()))
+                    data.keys(), timestamps.keys(), descriptor["data_keys"].keys()
+                )
+            )
         if set(filled) - set(data):
             raise EventModelValidationError(
                 "Keys in event['filled'] {} must be a subset of those in "
-                "event['data'] {}".format(filled.keys(), data.keys()))
-    event_counter[descriptor['name']] += len(data)
+                "event['data'] {}".format(filled.keys(), data.keys())
+            )
+    event_counter[descriptor["name"]] += len(data)
     return doc
 
 
-def compose_event(*, descriptor, event_counter, data, timestamps, seq_num=None,
-                  filled=None, uid=None, time=None, validate=True):
+def compose_event(
+    *,
+    descriptor,
+    event_counter,
+    data,
+    timestamps,
+    seq_num=None,
+    filled=None,
+    uid=None,
+    time=None,
+    validate=True,
+):
     if seq_num is None:
-        seq_num = event_counter[descriptor['name']]
+        seq_num = event_counter[descriptor["name"]]
     if uid is None:
         uid = str(uuid.uuid4())
     if time is None:
         time = ttime.time()
     if filled is None:
         filled = {}
-    doc = {'uid': uid,
-           'time': time,
-           'data': data,
-           'timestamps': timestamps,
-           'seq_num': seq_num,
-           'filled': filled,
-           'descriptor': descriptor['uid']}
+    doc = {
+        "uid": uid,
+        "time": time,
+        "data": data,
+        "timestamps": timestamps,
+        "seq_num": seq_num,
+        "filled": filled,
+        "descriptor": descriptor["uid"],
+    }
     if validate:
         schema_validators[DocumentNames.event].validate(doc)
-        if not (descriptor['data_keys'].keys() == data.keys() == timestamps.keys()):
+        if not (descriptor["data_keys"].keys() == data.keys() == timestamps.keys()):
             raise EventModelValidationError(
                 "These sets of keys must match:\n"
                 "event['data'].keys(): {}\n"
                 "event['timestamps'].keys(): {}\n"
                 "descriptor['data_keys'].keys(): {}\n".format(
-                    data.keys(), timestamps.keys(), descriptor['data_keys'].keys()))
+                    data.keys(), timestamps.keys(), descriptor["data_keys"].keys()
+                )
+            )
         if set(filled) - set(data):
             raise EventModelValidationError(
                 "Keys in event['filled'] {} must be a subset of those in "
-                "event['data'] {}".format(filled.keys(), data.keys()))
-    event_counter[descriptor['name']] += 1
+                "event['data'] {}".format(filled.keys(), data.keys())
+            )
+    event_counter[descriptor["name"]] += 1
     return doc
 
 
-def compose_descriptor(*, start, streams, event_counter,
-                       name, data_keys, uid=None, time=None,
-                       object_keys=None, configuration=None, hints=None,
-                       validate=True):
+def compose_descriptor(
+    *,
+    start,
+    streams,
+    event_counter,
+    name,
+    data_keys,
+    uid=None,
+    time=None,
+    object_keys=None,
+    configuration=None,
+    hints=None,
+    validate=True,
+):
     if uid is None:
         uid = str(uuid.uuid4())
     if time is None:
@@ -1861,21 +2105,24 @@ def compose_descriptor(*, start, streams, event_counter,
         configuration = {}
     if hints is None:
         hints = {}
-    doc = {'uid': uid,
-           'time': time,
-           'run_start': start['uid'],
-           'name': name,
-           'data_keys': data_keys,
-           'object_keys': object_keys,
-           'hints': hints,
-           'configuration': configuration}
+    doc = {
+        "uid": uid,
+        "time": time,
+        "run_start": start["uid"],
+        "name": name,
+        "data_keys": data_keys,
+        "object_keys": object_keys,
+        "hints": hints,
+        "configuration": configuration,
+    }
     if validate:
         if name in streams and streams[name] != set(data_keys):
             raise EventModelValidationError(
                 "A descriptor with the name {} has already been composed with "
                 "data_keys {}. The requested data_keys were {}. All "
                 "descriptors in a given stream must have the same "
-                "data_keys.".format(name, streams[name], set(data_keys)))
+                "data_keys.".format(name, streams[name], set(data_keys))
+            )
         schema_validators[DocumentNames.descriptor].validate(doc)
     if name not in streams:
         streams[name] = set(data_keys)
@@ -1883,7 +2130,8 @@ def compose_descriptor(*, start, streams, event_counter,
     return ComposeDescriptorBundle(
         doc,
         partial(compose_event, descriptor=doc, event_counter=event_counter),
-        partial(compose_event_page, descriptor=doc, event_counter=event_counter))
+        partial(compose_event_page, descriptor=doc, event_counter=event_counter),
+    )
 
 
 def compose_run(*, uid=None, time=None, metadata=None, validate=True):
@@ -1924,12 +2172,17 @@ def compose_run(*, uid=None, time=None, metadata=None, validate=True):
 
     return ComposeRunBundle(
         doc,
-        partial(compose_descriptor, start=doc, streams=streams,
-                event_counter=event_counter),
+        partial(
+            compose_descriptor, start=doc, streams=streams, event_counter=event_counter
+        ),
         partial(compose_resource, start=doc),
-        partial(compose_stop, start=doc, event_counter=event_counter,
-                poison_pill=poison_pill),
-        compose_stream_resource=partial(compose_stream_resource, start=doc)
+        partial(
+            compose_stop,
+            start=doc,
+            event_counter=event_counter,
+            poison_pill=poison_pill,
+        ),
+        compose_stream_resource=partial(compose_stream_resource, start=doc),
     )
 
 
@@ -1950,7 +2203,8 @@ def pack_event_page(*events):
         raise ValueError(
             "The pack_event_page() function was called with empty *args. "
             "Cannot create an EventPage from an empty collection of Events "
-            "because the 'descriptor' field in an EventPage cannot be NULL.")
+            "because the 'descriptor' field in an EventPage cannot be NULL."
+        )
     time_list = []
     uid_list = []
     seq_num_list = []
@@ -1958,17 +2212,21 @@ def pack_event_page(*events):
     filled_list = []
     timestamps_list = []
     for event in events:
-        time_list.append(event['time'])
-        uid_list.append(event['uid'])
-        seq_num_list.append(event['seq_num'])
-        filled_list.append(event.get('filled', {}))
-        data_list.append(event['data'])
-        timestamps_list.append(event['timestamps'])
-    event_page = {'time': time_list, 'uid': uid_list, 'seq_num': seq_num_list,
-                  'descriptor': event['descriptor'],
-                  'filled': _transpose_list_of_dicts(filled_list),
-                  'data': _transpose_list_of_dicts(data_list),
-                  'timestamps': _transpose_list_of_dicts(timestamps_list)}
+        time_list.append(event["time"])
+        uid_list.append(event["uid"])
+        seq_num_list.append(event["seq_num"])
+        filled_list.append(event.get("filled", {}))
+        data_list.append(event["data"])
+        timestamps_list.append(event["timestamps"])
+    event_page = {
+        "time": time_list,
+        "uid": uid_list,
+        "seq_num": seq_num_list,
+        "descriptor": event["descriptor"],
+        "filled": _transpose_list_of_dicts(filled_list),
+        "data": _transpose_list_of_dicts(data_list),
+        "timestamps": _transpose_list_of_dicts(timestamps_list),
+    }
     return event_page
 
 
@@ -1984,21 +2242,28 @@ def unpack_event_page(event_page):
     ------
     event : dict
     """
-    descriptor = event_page['descriptor']
-    data_list = _transpose_dict_of_lists(event_page['data'])
-    timestamps_list = _transpose_dict_of_lists(event_page['timestamps'])
-    filled_list = _transpose_dict_of_lists(event_page.get('filled', {}))
+    descriptor = event_page["descriptor"]
+    data_list = _transpose_dict_of_lists(event_page["data"])
+    timestamps_list = _transpose_dict_of_lists(event_page["timestamps"])
+    filled_list = _transpose_dict_of_lists(event_page.get("filled", {}))
     for uid, time, seq_num, data, timestamps, filled in itertools.zip_longest(
-            event_page['uid'],
-            event_page['time'],
-            event_page['seq_num'],
-            data_list,
-            timestamps_list,
-            filled_list,
-            fillvalue={}):
-        event = {'descriptor': descriptor,
-                 'uid': uid, 'time': time, 'seq_num': seq_num,
-                 'data': data, 'timestamps': timestamps, 'filled': filled}
+        event_page["uid"],
+        event_page["time"],
+        event_page["seq_num"],
+        data_list,
+        timestamps_list,
+        filled_list,
+        fillvalue={},
+    ):
+        event = {
+            "descriptor": descriptor,
+            "uid": uid,
+            "time": time,
+            "seq_num": seq_num,
+            "data": data,
+            "timestamps": timestamps,
+            "filled": filled,
+        }
         yield event
 
 
@@ -2019,15 +2284,18 @@ def pack_datum_page(*datum):
         raise ValueError(
             "The pack_datum_page() function was called with empty *args. "
             "Cannot create an DatumPage from an empty collection of Datum "
-            "because the 'resource' field in a DatumPage cannot be NULL.")
+            "because the 'resource' field in a DatumPage cannot be NULL."
+        )
     datum_id_list = []
     datum_kwarg_list = []
     for datum_ in datum:
-        datum_id_list.append(datum_['datum_id'])
-        datum_kwarg_list.append(datum_['datum_kwargs'])
-    datum_page = {'resource': datum_['resource'],
-                  'datum_id': datum_id_list,
-                  'datum_kwargs': _transpose_list_of_dicts(datum_kwarg_list)}
+        datum_id_list.append(datum_["datum_id"])
+        datum_kwarg_list.append(datum_["datum_kwargs"])
+    datum_page = {
+        "resource": datum_["resource"],
+        "datum_id": datum_id_list,
+        "datum_kwargs": _transpose_list_of_dicts(datum_kwarg_list),
+    }
     return datum_page
 
 
@@ -2043,14 +2311,16 @@ def unpack_datum_page(datum_page):
     ------
     datum : dict
     """
-    resource = datum_page['resource']
-    datum_kwarg_list = _transpose_dict_of_lists(datum_page['datum_kwargs'])
+    resource = datum_page["resource"]
+    datum_kwarg_list = _transpose_dict_of_lists(datum_page["datum_kwargs"])
     for datum_id, datum_kwargs in itertools.zip_longest(
-            datum_page['datum_id'],
-            datum_kwarg_list,
-            fillvalue={}):
-        datum = {'datum_id': datum_id, 'datum_kwargs': datum_kwargs,
-                 'resource': resource}
+        datum_page["datum_id"], datum_kwarg_list, fillvalue={}
+    ):
+        datum = {
+            "datum_id": datum_id,
+            "datum_kwargs": datum_kwargs,
+            "resource": resource,
+        }
         yield datum
 
 
@@ -2078,28 +2348,35 @@ def page_chunks(page, chunk_size, remainder):
         The first chunk will be of size remainder, the following chunks will be
         of size chunk_size. The last chunk will be what ever is left over.
         """
-        array_keys = ['seq_num', 'time', 'uid']
-        page_size = len(page['uid'])  # Number of events in the page.
+        array_keys = ["seq_num", "time", "uid"]
+        page_size = len(page["uid"])  # Number of events in the page.
 
         # Make a list of the chunk indexes.
         chunks = [(0, remainder)]
-        chunks.extend([(i, i + chunk_size) for i
-                       in range(remainder, page_size, chunk_size)])
+        chunks.extend(
+            [(i, i + chunk_size) for i in range(remainder, page_size, chunk_size)]
+        )
 
         for start, stop in chunks:
-            yield {'descriptor': page['descriptor'],
-                   **{key: page[key][start:stop] for key in array_keys},
-                   'data': {key: page['data'][key][start:stop]
-                            for key in page['data'].keys()},
-                   'timestamps': {key: page['timestamps'][key][start: stop]
-                                  for key in page['timestamps'].keys()},
-                   'filled': {key: page['filled'][key][start:stop]
-                              for key in page['data'].keys()}}
+            yield {
+                "descriptor": page["descriptor"],
+                **{key: page[key][start:stop] for key in array_keys},
+                "data": {
+                    key: page["data"][key][start:stop] for key in page["data"].keys()
+                },
+                "timestamps": {
+                    key: page["timestamps"][key][start:stop]
+                    for key in page["timestamps"].keys()
+                },
+                "filled": {
+                    key: page["filled"][key][start:stop] for key in page["data"].keys()
+                },
+            }
 
     for page in event_pages:
         new_chunks = page_chunks(page, chunk_size, remainder)
         for chunk in new_chunks:
-            remainder -= len(chunk['uid'])  # Subtract the size of the chunk.
+            remainder -= len(chunk["uid"])  # Subtract the size of the chunk.
             chunk_list.append(chunk)
             if remainder == 0:
                 yield merge_event_pages(chunk_list)
@@ -2126,20 +2403,35 @@ def merge_event_pages(event_pages):
     if len(pages) == 1:
         return pages[0]
 
-    array_keys = ['seq_num', 'time', 'uid']
-
-    return {'descriptor': pages[0]['descriptor'],
-            **{key: list(itertools.chain.from_iterable(
-                    [page[key] for page in pages])) for key in array_keys},
-            'data': {key: list(itertools.chain.from_iterable(
-                    [page['data'][key] for page in pages]))
-                    for key in pages[0]['data'].keys()},
-            'timestamps': {key: list(itertools.chain.from_iterable(
-                    [page['timestamps'][key] for page in pages]))
-                    for key in pages[0]['data'].keys()},
-            'filled': {key: list(itertools.chain.from_iterable(
-                    [page['filled'][key] for page in pages]))
-                    for key in pages[0]['data'].keys()}}
+    array_keys = ["seq_num", "time", "uid"]
+
+    return {
+        "descriptor": pages[0]["descriptor"],
+        **{
+            key: list(itertools.chain.from_iterable([page[key] for page in pages]))
+            for key in array_keys
+        },
+        "data": {
+            key: list(
+                itertools.chain.from_iterable([page["data"][key] for page in pages])
+            )
+            for key in pages[0]["data"].keys()
+        },
+        "timestamps": {
+            key: list(
+                itertools.chain.from_iterable(
+                    [page["timestamps"][key] for page in pages]
+                )
+            )
+            for key in pages[0]["data"].keys()
+        },
+        "filled": {
+            key: list(
+                itertools.chain.from_iterable([page["filled"][key] for page in pages])
+            )
+            for key in pages[0]["data"].keys()
+        },
+    }
 
 
 def rechunk_datum_pages(datum_pages, chunk_size):
@@ -2167,24 +2459,29 @@ def page_chunks(page, chunk_size, remainder):
         of size chunk_size. The last chunk will be what ever is left over.
         """
 
-        array_keys = ['datum_id']
-        page_size = len(page['datum_id'])  # Number of datum in the page.
+        array_keys = ["datum_id"]
+        page_size = len(page["datum_id"])  # Number of datum in the page.
 
         # Make a list of the chunk indexes.
         chunks = [(0, remainder)]
-        chunks.extend([(i, i + chunk_size) for i
-                       in range(remainder, page_size, chunk_size)])
+        chunks.extend(
+            [(i, i + chunk_size) for i in range(remainder, page_size, chunk_size)]
+        )
 
         for start, stop in chunks:
-            yield {'resource': page['resource'],
-                   **{key: page[key][start:stop] for key in array_keys},
-                   'datum_kwargs': {key: page['datum_kwargs'][key][start:stop]
-                                    for key in page['datum_kwargs'].keys()}}
+            yield {
+                "resource": page["resource"],
+                **{key: page[key][start:stop] for key in array_keys},
+                "datum_kwargs": {
+                    key: page["datum_kwargs"][key][start:stop]
+                    for key in page["datum_kwargs"].keys()
+                },
+            }
 
     for page in datum_pages:
         new_chunks = page_chunks(page, chunk_size, remainder)
         for chunk in new_chunks:
-            remainder -= len(chunk['datum_id'])  # Subtract the size of the chunk.
+            remainder -= len(chunk["datum_id"])  # Subtract the size of the chunk.
             chunk_list.append(chunk)
             if remainder == 0:
                 yield merge_datum_pages(chunk_list)
@@ -2211,14 +2508,23 @@ def merge_datum_pages(datum_pages):
     if len(pages) == 1:
         return pages[0]
 
-    array_keys = ['datum_id']
-
-    return {'resource': pages[0]['resource'],
-            **{key: list(itertools.chain.from_iterable(
-                    [page[key] for page in pages])) for key in array_keys},
-            'datum_kwargs': {key: list(itertools.chain.from_iterable(
-                    [page['datum_kwargs'][key] for page in pages]))
-                    for key in pages[0]['datum_kwargs'].keys()}}
+    array_keys = ["datum_id"]
+
+    return {
+        "resource": pages[0]["resource"],
+        **{
+            key: list(itertools.chain.from_iterable([page[key] for page in pages]))
+            for key in array_keys
+        },
+        "datum_kwargs": {
+            key: list(
+                itertools.chain.from_iterable(
+                    [page["datum_kwargs"][key] for page in pages]
+                )
+            )
+            for key in pages[0]["datum_kwargs"].keys()
+        },
+    }
 
 
 def bulk_events_to_event_pages(bulk_events):
@@ -2240,27 +2546,26 @@ def bulk_events_to_event_pages(bulk_events):
     event_pages = {}  # descriptor uid mapped to page
     for events in bulk_events.values():
         for event in events:
-            descriptor = event['descriptor']
+            descriptor = event["descriptor"]
             try:
                 page = event_pages[descriptor]
             except KeyError:
-                page = {'time': [], 'uid': [], 'seq_num': [],
-                        'descriptor': descriptor}
-                page['data'] = {k: [] for k in event['data']}
-                page['timestamps'] = {k: [] for k in event['timestamps']}
-                page['filled'] = {k: [] for k in event.get('filled', {})}
+                page = {"time": [], "uid": [], "seq_num": [], "descriptor": descriptor}
+                page["data"] = {k: [] for k in event["data"]}
+                page["timestamps"] = {k: [] for k in event["timestamps"]}
+                page["filled"] = {k: [] for k in event.get("filled", {})}
                 event_pages[descriptor] = page
-            page['uid'].append(event['uid'])
-            page['time'].append(event['time'])
-            page['seq_num'].append(event['seq_num'])
-            page_data = page['data']
-            for k, v in event['data'].items():
+            page["uid"].append(event["uid"])
+            page["time"].append(event["time"])
+            page["seq_num"].append(event["seq_num"])
+            page_data = page["data"]
+            for k, v in event["data"].items():
                 page_data[k].append(v)
-            page_timestamps = page['timestamps']
-            for k, v in event['timestamps'].items():
+            page_timestamps = page["timestamps"]
+            for k, v in event["timestamps"].items():
                 page_timestamps[k].append(v)
-            page_filled = page['filled']
-            for k, v in event.get('filled', {}).items():
+            page_filled = page["filled"]
+            for k, v in event.get("filled", {}).items():
                 page_filled[k].append(v)
     return list(event_pages.values())
 
@@ -2272,10 +2577,11 @@ def bulk_datum_to_datum_page(bulk_datum):
     Note: There is only one known usage of BulkDatum "in the wild", and the
     BulkDatum layout has been deprecated in favor of DatumPage.
     """
-    datum_page = {'datum_id': bulk_datum['datum_ids'],
-                  'resource': bulk_datum['resource'],
-                  'datum_kwargs': _transpose_list_of_dicts(
-                      bulk_datum['datum_kwarg_list'])}
+    datum_page = {
+        "datum_id": bulk_datum["datum_ids"],
+        "resource": bulk_datum["resource"],
+        "datum_kwargs": _transpose_list_of_dicts(bulk_datum["datum_kwarg_list"]),
+    }
     return datum_page
 
 
@@ -2298,7 +2604,7 @@ def _transpose_dict_of_lists(dict_of_lists):
 
 
 def verify_filled(event_page):
-    '''Take an event_page document and verify that it is completely filled.
+    """Take an event_page document and verify that it is completely filled.
 
     Parameters
     ----------
@@ -2310,20 +2616,22 @@ def verify_filled(event_page):
     UnfilledData
         Raised if any of the data in the event_page is unfilled, when raised it
         inlcudes a list of unfilled data objects in the exception message.
-    '''
-    if not all(map(all, event_page['filled'].values())):
+    """
+    if not all(map(all, event_page["filled"].values())):
         # check that all event_page data is filled.
         unfilled_data = []
-        for field, filled in event_page['filled'].items():
+        for field, filled in event_page["filled"].items():
             if not all(filled):
                 unfilled_data.append(field)
-                raise UnfilledData(f"Unfilled data found in fields "
-                                   f"{unfilled_data!r}. Use "
-                                   f"`event_model.Filler`.")
+                raise UnfilledData(
+                    f"Unfilled data found in fields "
+                    f"{unfilled_data!r}. Use "
+                    f"`event_model.Filler`."
+                )
 
 
 def sanitize_doc(doc):
-    '''Return a copy with any numpy objects converted to built-in Python types.
+    """Return a copy with any numpy objects converted to built-in Python types.
 
     This function takes in an event-model document and returns a copy with any
     numpy objects converted to built-in Python types. It is useful for
@@ -2340,7 +2648,7 @@ def sanitize_doc(doc):
     sanitized_doc : event-model document
         The event-model document with numpy objects converted to built-in
         Python types.
-    '''
+    """
     return json.loads(json.dumps(doc, cls=NumpyEncoder))
 
 
@@ -2355,10 +2663,12 @@ class NumpyEncoder(json.JSONEncoder):
 
     >>> json.dumps({'a': {'b': numpy.array([1, 2, 3])}}, cls=NumpyEncoder)
     """
+
     # Credit: https://stackoverflow.com/a/47626762/1221924
     def default(self, obj):
         try:
             import dask.array
+
             if isinstance(obj, dask.array.Array):
                 obj = numpy.asarray(obj)
         except ImportError:
diff --git a/event_model/__main__.py b/event_model/__main__.py
new file mode 100644
index 000000000..cc35ce8ab
--- /dev/null
+++ b/event_model/__main__.py
@@ -0,0 +1,16 @@
+from argparse import ArgumentParser
+
+from event_model import __version__
+
+__all__ = ["main"]
+
+
+def main(args=None):
+    parser = ArgumentParser()
+    parser.add_argument("-v", "--version", action="version", version=__version__)
+    args = parser.parse_args(args)
+
+
+# test with: python -m python3_pip_skeleton
+if __name__ == "__main__":
+    main()
diff --git a/event_model/_version.py b/event_model/_version.py
index cbb5cb28a..42ba1aa79 100644
--- a/event_model/_version.py
+++ b/event_model/_version.py
@@ -1,4 +1,3 @@
-
 # This file helps to compute a version number in source trees obtained from
 # git-archive tarball (such as those provided by githubs download-from-tag
 # feature). Distribution tarballs (built by setup.py sdist) and build
@@ -58,17 +57,18 @@ class NotThisMethod(Exception):
 
 def register_vcs_handler(vcs, method):  # decorator
     """Decorator to mark a method as the handler for a particular VCS."""
+
     def decorate(f):
         """Store f in HANDLERS[vcs][method]."""
         if vcs not in HANDLERS:
             HANDLERS[vcs] = {}
         HANDLERS[vcs][method] = f
         return f
+
     return decorate
 
 
-def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
-                env=None):
+def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None):
     """Call the given command(s)."""
     assert isinstance(commands, list)
     p = None
@@ -76,10 +76,13 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
         try:
             dispcmd = str([c] + args)
             # remember shell=False, so use git.cmd on windows, not just git
-            p = subprocess.Popen([c] + args, cwd=cwd, env=env,
-                                 stdout=subprocess.PIPE,
-                                 stderr=(subprocess.PIPE if hide_stderr
-                                         else None))
+            p = subprocess.Popen(
+                [c] + args,
+                cwd=cwd,
+                env=env,
+                stdout=subprocess.PIPE,
+                stderr=(subprocess.PIPE if hide_stderr else None),
+            )
             break
         except EnvironmentError:
             e = sys.exc_info()[1]
@@ -116,16 +119,22 @@ def versions_from_parentdir(parentdir_prefix, root, verbose):
     for i in range(3):
         dirname = os.path.basename(root)
         if dirname.startswith(parentdir_prefix):
-            return {"version": dirname[len(parentdir_prefix):],
-                    "full-revisionid": None,
-                    "dirty": False, "error": None, "date": None}
+            return {
+                "version": dirname[len(parentdir_prefix) :],
+                "full-revisionid": None,
+                "dirty": False,
+                "error": None,
+                "date": None,
+            }
         else:
             rootdirs.append(root)
             root = os.path.dirname(root)  # up a level
 
     if verbose:
-        print("Tried directories %s but none started with prefix %s" %
-              (str(rootdirs), parentdir_prefix))
+        print(
+            "Tried directories %s but none started with prefix %s"
+            % (str(rootdirs), parentdir_prefix)
+        )
     raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
 
 
@@ -181,7 +190,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
     # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
     # just "foo-1.0". If we see a "tag: " prefix, prefer those.
     TAG = "tag: "
-    tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)])
+    tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)])
     if not tags:
         # Either we're using git < 1.8.3, or there really are no tags. We use
         # a heuristic: assume all version tags have a digit. The old git %d
@@ -190,7 +199,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
         # between branches and tags. By ignoring refnames without digits, we
         # filter out many common branch names like "release" and
         # "stabilization", as well as "HEAD" and "master".
-        tags = set([r for r in refs if re.search(r'\d', r)])
+        tags = set([r for r in refs if re.search(r"\d", r)])
         if verbose:
             print("discarding '%s', no digits" % ",".join(refs - tags))
     if verbose:
@@ -198,19 +207,26 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose):
     for ref in sorted(tags):
         # sorting will prefer e.g. "2.0" over "2.0rc1"
         if ref.startswith(tag_prefix):
-            r = ref[len(tag_prefix):]
+            r = ref[len(tag_prefix) :]
             if verbose:
                 print("picking %s" % r)
-            return {"version": r,
-                    "full-revisionid": keywords["full"].strip(),
-                    "dirty": False, "error": None,
-                    "date": date}
+            return {
+                "version": r,
+                "full-revisionid": keywords["full"].strip(),
+                "dirty": False,
+                "error": None,
+                "date": date,
+            }
     # no suitable tags, so version is "0+unknown", but full hex is still there
     if verbose:
         print("no suitable tags, using unknown + full revision id")
-    return {"version": "0+unknown",
-            "full-revisionid": keywords["full"].strip(),
-            "dirty": False, "error": "no suitable tags", "date": None}
+    return {
+        "version": "0+unknown",
+        "full-revisionid": keywords["full"].strip(),
+        "dirty": False,
+        "error": "no suitable tags",
+        "date": None,
+    }
 
 
 @register_vcs_handler("git", "pieces_from_vcs")
@@ -225,8 +241,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
     if sys.platform == "win32":
         GITS = ["git.cmd", "git.exe"]
 
-    out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root,
-                          hide_stderr=True)
+    out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True)
     if rc != 0:
         if verbose:
             print("Directory %s not under git control" % root)
@@ -234,10 +249,19 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
 
     # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
     # if there isn't one, this yields HEX[-dirty] (no NUM)
-    describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty",
-                                          "--always", "--long",
-                                          "--match", "%s*" % tag_prefix],
-                                   cwd=root)
+    describe_out, rc = run_command(
+        GITS,
+        [
+            "describe",
+            "--tags",
+            "--dirty",
+            "--always",
+            "--long",
+            "--match",
+            "%s*" % tag_prefix,
+        ],
+        cwd=root,
+    )
     # --long was added in git-1.5.5
     if describe_out is None:
         raise NotThisMethod("'git describe' failed")
@@ -260,17 +284,16 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
     dirty = git_describe.endswith("-dirty")
     pieces["dirty"] = dirty
     if dirty:
-        git_describe = git_describe[:git_describe.rindex("-dirty")]
+        git_describe = git_describe[: git_describe.rindex("-dirty")]
 
     # now we have TAG-NUM-gHEX or HEX
 
     if "-" in git_describe:
         # TAG-NUM-gHEX
-        mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
+        mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe)
         if not mo:
             # unparseable. Maybe git-describe is misbehaving?
-            pieces["error"] = ("unable to parse git-describe output: '%s'"
-                               % describe_out)
+            pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out
             return pieces
 
         # tag
@@ -279,10 +302,12 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
             if verbose:
                 fmt = "tag '%s' doesn't start with prefix '%s'"
                 print(fmt % (full_tag, tag_prefix))
-            pieces["error"] = ("tag '%s' doesn't start with prefix '%s'"
-                               % (full_tag, tag_prefix))
+            pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (
+                full_tag,
+                tag_prefix,
+            )
             return pieces
-        pieces["closest-tag"] = full_tag[len(tag_prefix):]
+        pieces["closest-tag"] = full_tag[len(tag_prefix) :]
 
         # distance: number of commits since tag
         pieces["distance"] = int(mo.group(2))
@@ -293,13 +318,13 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
     else:
         # HEX: no tags
         pieces["closest-tag"] = None
-        count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"],
-                                    cwd=root)
+        count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root)
         pieces["distance"] = int(count_out)  # total number of commits
 
     # commit date: see ISO-8601 comment in git_versions_from_keywords()
-    date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"],
-                       cwd=root)[0].strip()
+    date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[
+        0
+    ].strip()
     pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
 
     return pieces
@@ -330,8 +355,7 @@ def render_pep440(pieces):
                 rendered += ".dirty"
     else:
         # exception #1
-        rendered = "0+untagged.%d.g%s" % (pieces["distance"],
-                                          pieces["short"])
+        rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"])
         if pieces["dirty"]:
             rendered += ".dirty"
     return rendered
@@ -445,11 +469,13 @@ def render_git_describe_long(pieces):
 def render(pieces, style):
     """Render the given version pieces into the requested style."""
     if pieces["error"]:
-        return {"version": "unknown",
-                "full-revisionid": pieces.get("long"),
-                "dirty": None,
-                "error": pieces["error"],
-                "date": None}
+        return {
+            "version": "unknown",
+            "full-revisionid": pieces.get("long"),
+            "dirty": None,
+            "error": pieces["error"],
+            "date": None,
+        }
 
     if not style or style == "default":
         style = "pep440"  # the default
@@ -469,9 +495,13 @@ def render(pieces, style):
     else:
         raise ValueError("unknown style '%s'" % style)
 
-    return {"version": rendered, "full-revisionid": pieces["long"],
-            "dirty": pieces["dirty"], "error": None,
-            "date": pieces.get("date")}
+    return {
+        "version": rendered,
+        "full-revisionid": pieces["long"],
+        "dirty": pieces["dirty"],
+        "error": None,
+        "date": pieces.get("date"),
+    }
 
 
 def get_versions():
@@ -485,8 +515,7 @@ def get_versions():
     verbose = cfg.verbose
 
     try:
-        return git_versions_from_keywords(get_keywords(), cfg.tag_prefix,
-                                          verbose)
+        return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, verbose)
     except NotThisMethod:
         pass
 
@@ -495,13 +524,16 @@ def get_versions():
         # versionfile_source is the relative path from the top of the source
         # tree (where the .git directory might live) to this file. Invert
         # this to find the root from __file__.
-        for i in cfg.versionfile_source.split('/'):
+        for i in cfg.versionfile_source.split("/"):
             root = os.path.dirname(root)
     except NameError:
-        return {"version": "0+unknown", "full-revisionid": None,
-                "dirty": None,
-                "error": "unable to find root of source tree",
-                "date": None}
+        return {
+            "version": "0+unknown",
+            "full-revisionid": None,
+            "dirty": None,
+            "error": "unable to find root of source tree",
+            "date": None,
+        }
 
     try:
         pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose)
@@ -515,6 +547,10 @@ def get_versions():
     except NotThisMethod:
         pass
 
-    return {"version": "0+unknown", "full-revisionid": None,
-            "dirty": None,
-            "error": "unable to compute version", "date": None}
+    return {
+        "version": "0+unknown",
+        "full-revisionid": None,
+        "dirty": None,
+        "error": "unable to compute version",
+        "date": None,
+    }
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 000000000..f052dc5ee
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,127 @@
+[build-system]
+requires = ["setuptools>=64", "setuptools_scm[toml]>=6.2", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "event-model"
+classifiers = [
+    "Development Status :: 3 - Alpha",
+    "License :: OSI Approved :: BSD License",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+]
+description = "Data model used by the bluesky ecosystem"
+dependencies = [
+    "importlib-metadata",
+    "jsonschema",
+    "numpy",
+    "packaging"
+]
+dynamic = ["version"]
+license.file = "LICENSE"
+readme = "README.rst"
+requires-python = ">=3.8"
+
+[project.optional-dependencies]
+dev = [
+    "dask[array]",
+    "black",
+    "mypy",
+    "flake8",
+    "flake8-isort",
+    "Flake8-pyproject",
+    "pipdeptree",
+    "pre-commit",
+    "pydata-sphinx-theme>=0.12",
+    "pytest",
+    "pytest-cov",
+    "sphinx",
+    "sphinx-autobuild",
+    "sphinx-copybutton",
+    "sphinx-design",
+    "tox-direct",
+    "types-mock",
+
+    # These are dependencies of various sphinx extensions for documentation.
+    "ipython",
+    "matplotlib",
+    "numpydoc",
+]
+
+[project.scripts]
+# event_model = "event_model.__main__:main"
+
+[project.urls]
+GitHub = "https://github.com/bluesky/event-model"
+
+[[project.authors]] # Further authors may be added by duplicating this section
+email = "dallan@bnl.gov"
+name = "Brookhaven National Lab"
+
+[tool.setuptools_scm]
+
+[tool.mypy]
+# Ignore missing stubs in imported modules
+ignore_missing_imports = true
+
+
+[tool.isort]
+float_to_top = true
+profile = "black"
+
+[tool.flake8]
+extend-ignore = [
+    # See https://github.com/PyCQA/pycodestyle/issues/373
+    "E203",
+    # support typing.overload decorator
+    "F811",
+    # allow Annotated[typ, some_func("some string")]
+    "F722",
+]
+max-line-length = 88
+exclude = [".tox", ".venv", "venv"]
+
+
+[tool.pytest.ini_options]
+# Run pytest with all our checkers, and don't spam us with massive tracebacks on error
+addopts = """
+    --tb=native -vv
+    --cov=event_model --cov-report term --cov-report xml:cov.xml
+    """
+# https://iscinumpy.gitlab.io/post/bound-version-constraints/#watch-for-warnings
+filterwarnings = [
+    "error",
+    "ignore::DeprecationWarning"
+]
+# Doctest python code in docs, python code in src docstrings, test functions in tests
+testpaths = "event_model/tests"
+
+[tool.coverage.paths]
+# Tests are run from installed location, map back to the src directory
+source = ["event_model", "**/site-packages/"]
+
+# tox must currently be configured via an embedded ini string
+# See: https://github.com/tox-dev/tox/issues/999
+[tool.tox]
+legacy_tox_ini = """
+[tox]
+skipsdist=True
+
+[testenv:{pre-commit,mypy,pytest,docs}]
+# Don't create a virtualenv for the command, requires tox-direct plugin
+direct = True
+passenv = *
+allowlist_externals = 
+    pytest 
+    pre-commit
+    mypy
+    sphinx-build
+    sphinx-autobuild
+commands =
+    pytest: pytest {posargs}
+    mypy: mypy event_model {posargs}
+    pre-commit: pre-commit run --all-files {posargs}
+    docs: sphinx-{posargs:build -E --keep-going} -T docs build/html
+"""
diff --git a/requirements-dev.txt b/requirements-dev.txt
deleted file mode 100644
index e32fea35c..000000000
--- a/requirements-dev.txt
+++ /dev/null
@@ -1,14 +0,0 @@
-# These are required for developing the package (running the tests, building
-# the documentation) but not necessarily required for _using_ it.
-codecov
-coverage
-dask[array]
-flake8
-pytest >3.9
-sphinx
-# These are dependencies of various sphinx extensions for documentation.
-ipython
-matplotlib
-numpydoc
-sphinx-copybutton
-sphinx_rtd_theme
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 964de5ee1..000000000
--- a/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-importlib-metadata;python_version<'3.8'
-jsonschema
-numpy
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 88334168b..000000000
--- a/setup.cfg
+++ /dev/null
@@ -1,9 +0,0 @@
-[metadata]
-description-file = README.md
-
-[versioneer]
-VCS = git
-style = pep440-post
-versionfile_source = event_model/_version.py
-versionfile_build = event_model/_version.py
-tag_prefix = v
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 798bcc3dc..000000000
--- a/setup.py
+++ /dev/null
@@ -1,69 +0,0 @@
-from os import path
-from setuptools import setup, find_packages
-import sys
-import versioneer
-
-
-# NOTE: This file must remain Python 2 compatible for the foreseeable future,
-# to ensure that we error out properly for people with outdated setuptools
-# and/or pip.
-min_version = (3, 6)
-if sys.version_info < min_version:
-    error = """
-event-model does not support Python {0}.{1}.
-Python {2}.{3} and above is required. Check your Python version like so:
-
-python3 --version
-
-This may be due to an out-of-date pip. Make sure you have pip >= 9.0.1.
-Upgrade pip like so:
-
-pip install --upgrade pip
-""".format(*(sys.version_info[:2] + min_version))
-    sys.exit(error)
-
-here = path.abspath(path.dirname(__file__))
-
-with open(path.join(here, 'README.md'), encoding='utf-8') as readme_file:
-    readme = readme_file.read()
-
-with open(path.join(here, 'requirements.txt')) as requirements_file:
-    # Parse requirements.txt, ignoring any commented-out lines.
-    requirements = [line for line in requirements_file.read().splitlines()
-                    if not line.startswith('#')]
-
-
-setup(
-    name='event-model',
-    version=versioneer.get_version(),
-    cmdclass=versioneer.get_cmdclass(),
-    description="Data model used by the bluesky ecosystem",
-    long_description=readme,
-    long_description_content_type='text/markdown',
-    author="Brookhaven National Lab",
-    author_email='dallan@bnl.gov',
-    url='https://github.com/NSLS-II/event-model',
-    python_requires='>={}'.format('.'.join(str(n) for n in min_version)),
-    packages=find_packages(exclude=['docs', 'tests']),
-    entry_points={
-        'console_scripts': [
-            # 'some.module:some_function',
-            ],
-        },
-    include_package_data=True,
-    package_data={
-        'event_model': [
-            # When adding files here, remember to update MANIFEST.in as well,
-            # or else they will not be included in the distribution on PyPI!
-            # 'path/to/data_file',
-            'schemas/*.json'
-            ]
-        },
-    install_requires=requirements,
-    license="BSD (3-clause)",
-    classifiers=[
-        'Development Status :: 2 - Pre-Alpha',
-        'Natural Language :: English',
-        'Programming Language :: Python :: 3',
-    ],
-)
diff --git a/versioneer.py b/versioneer.py
deleted file mode 100644
index f84667eb7..000000000
--- a/versioneer.py
+++ /dev/null
@@ -1,1821 +0,0 @@
-
-# Version: 0.18
-
-"""The Versioneer - like a rocketeer, but for versions.
-
-The Versioneer
-==============
-
-* like a rocketeer, but for versions!
-* https://github.com/warner/python-versioneer
-* Brian Warner
-* License: Public Domain
-* Compatible With: python2.6, 2.7, 3.2, 3.3, 3.4, 3.5, 3.6, and pypy
-* [![Latest Version]
-(https://pypip.in/version/versioneer/badge.svg?style=flat)
-](https://pypi.python.org/pypi/versioneer/)
-* [![Build Status]
-(https://travis-ci.org/warner/python-versioneer.png?branch=master)
-](https://travis-ci.org/warner/python-versioneer)
-
-This is a tool for managing a recorded version number in distutils-based
-python projects. The goal is to remove the tedious and error-prone "update
-the embedded version string" step from your release process. Making a new
-release should be as easy as recording a new tag in your version-control
-system, and maybe making new tarballs.
-
-
-## Quick Install
-
-* `pip install versioneer` to somewhere to your $PATH
-* add a `[versioneer]` section to your setup.cfg (see below)
-* run `versioneer install` in your source tree, commit the results
-
-## Version Identifiers
-
-Source trees come from a variety of places:
-
-* a version-control system checkout (mostly used by developers)
-* a nightly tarball, produced by build automation
-* a snapshot tarball, produced by a web-based VCS browser, like github's
-  "tarball from tag" feature
-* a release tarball, produced by "setup.py sdist", distributed through PyPI
-
-Within each source tree, the version identifier (either a string or a number,
-this tool is format-agnostic) can come from a variety of places:
-
-* ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows
-  about recent "tags" and an absolute revision-id
-* the name of the directory into which the tarball was unpacked
-* an expanded VCS keyword ($Id$, etc)
-* a `_version.py` created by some earlier build step
-
-For released software, the version identifier is closely related to a VCS
-tag. Some projects use tag names that include more than just the version
-string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool
-needs to strip the tag prefix to extract the version identifier. For
-unreleased software (between tags), the version identifier should provide
-enough information to help developers recreate the same tree, while also
-giving them an idea of roughly how old the tree is (after version 1.2, before
-version 1.3). Many VCS systems can report a description that captures this,
-for example `git describe --tags --dirty --always` reports things like
-"0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the
-0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has
-uncommitted changes.
-
-The version identifier is used for multiple purposes:
-
-* to allow the module to self-identify its version: `myproject.__version__`
-* to choose a name and prefix for a 'setup.py sdist' tarball
-
-## Theory of Operation
-
-Versioneer works by adding a special `_version.py` file into your source
-tree, where your `__init__.py` can import it. This `_version.py` knows how to
-dynamically ask the VCS tool for version information at import time.
-
-`_version.py` also contains `$Revision$` markers, and the installation
-process marks `_version.py` to have this marker rewritten with a tag name
-during the `git archive` command. As a result, generated tarballs will
-contain enough information to get the proper version.
-
-To allow `setup.py` to compute a version too, a `versioneer.py` is added to
-the top level of your source tree, next to `setup.py` and the `setup.cfg`
-that configures it. This overrides several distutils/setuptools commands to
-compute the version when invoked, and changes `setup.py build` and `setup.py
-sdist` to replace `_version.py` with a small static file that contains just
-the generated version data.
-
-## Installation
-
-See [INSTALL.md](./INSTALL.md) for detailed installation instructions.
-
-## Version-String Flavors
-
-Code which uses Versioneer can learn about its version string at runtime by
-importing `_version` from your main `__init__.py` file and running the
-`get_versions()` function. From the "outside" (e.g. in `setup.py`), you can
-import the top-level `versioneer.py` and run `get_versions()`.
-
-Both functions return a dictionary with different flavors of version
-information:
-
-* `['version']`: A condensed version string, rendered using the selected
-  style. This is the most commonly used value for the project's version
-  string. The default "pep440" style yields strings like `0.11`,
-  `0.11+2.g1076c97`, or `0.11+2.g1076c97.dirty`. See the "Styles" section
-  below for alternative styles.
-
-* `['full-revisionid']`: detailed revision identifier. For Git, this is the
-  full SHA1 commit id, e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac".
-
-* `['date']`: Date and time of the latest `HEAD` commit. For Git, it is the
-  commit date in ISO 8601 format. This will be None if the date is not
-  available.
-
-* `['dirty']`: a boolean, True if the tree has uncommitted changes. Note that
-  this is only accurate if run in a VCS checkout, otherwise it is likely to
-  be False or None
-
-* `['error']`: if the version string could not be computed, this will be set
-  to a string describing the problem, otherwise it will be None. It may be
-  useful to throw an exception in setup.py if this is set, to avoid e.g.
-  creating tarballs with a version string of "unknown".
-
-Some variants are more useful than others. Including `full-revisionid` in a
-bug report should allow developers to reconstruct the exact code being tested
-(or indicate the presence of local changes that should be shared with the
-developers). `version` is suitable for display in an "about" box or a CLI
-`--version` output: it can be easily compared against release notes and lists
-of bugs fixed in various releases.
-
-The installer adds the following text to your `__init__.py` to place a basic
-version in `YOURPROJECT.__version__`:
-
-    from ._version import get_versions
-    __version__ = get_versions()['version']
-    del get_versions
-
-## Styles
-
-The setup.cfg `style=` configuration controls how the VCS information is
-rendered into a version string.
-
-The default style, "pep440", produces a PEP440-compliant string, equal to the
-un-prefixed tag name for actual releases, and containing an additional "local
-version" section with more detail for in-between builds. For Git, this is
-TAG[+DISTANCE.gHEX[.dirty]] , using information from `git describe --tags
---dirty --always`. For example "0.11+2.g1076c97.dirty" indicates that the
-tree is like the "1076c97" commit but has uncommitted changes (".dirty"), and
-that this commit is two revisions ("+2") beyond the "0.11" tag. For released
-software (exactly equal to a known tag), the identifier will only contain the
-stripped tag, e.g. "0.11".
-
-Other styles are available. See [details.md](details.md) in the Versioneer
-source tree for descriptions.
-
-## Debugging
-
-Versioneer tries to avoid fatal errors: if something goes wrong, it will tend
-to return a version of "0+unknown". To investigate the problem, run `setup.py
-version`, which will run the version-lookup code in a verbose mode, and will
-display the full contents of `get_versions()` (including the `error` string,
-which may help identify what went wrong).
-
-## Known Limitations
-
-Some situations are known to cause problems for Versioneer. This details the
-most significant ones. More can be found on Github
-[issues page](https://github.com/warner/python-versioneer/issues).
-
-### Subprojects
-
-Versioneer has limited support for source trees in which `setup.py` is not in
-the root directory (e.g. `setup.py` and `.git/` are *not* siblings). The are
-two common reasons why `setup.py` might not be in the root:
-
-* Source trees which contain multiple subprojects, such as
-  [Buildbot](https://github.com/buildbot/buildbot), which contains both
-  "master" and "slave" subprojects, each with their own `setup.py`,
-  `setup.cfg`, and `tox.ini`. Projects like these produce multiple PyPI
-  distributions (and upload multiple independently-installable tarballs).
-* Source trees whose main purpose is to contain a C library, but which also
-  provide bindings to Python (and perhaps other langauges) in subdirectories.
-
-Versioneer will look for `.git` in parent directories, and most operations
-should get the right version string. However `pip` and `setuptools` have bugs
-and implementation details which frequently cause `pip install .` from a
-subproject directory to fail to find a correct version string (so it usually
-defaults to `0+unknown`).
-
-`pip install --editable .` should work correctly. `setup.py install` might
-work too.
-
-Pip-8.1.1 is known to have this problem, but hopefully it will get fixed in
-some later version.
-
-[Bug #38](https://github.com/warner/python-versioneer/issues/38) is tracking
-this issue. The discussion in
-[PR #61](https://github.com/warner/python-versioneer/pull/61) describes the
-issue from the Versioneer side in more detail.
-[pip PR#3176](https://github.com/pypa/pip/pull/3176) and
-[pip PR#3615](https://github.com/pypa/pip/pull/3615) contain work to improve
-pip to let Versioneer work correctly.
-
-Versioneer-0.16 and earlier only looked for a `.git` directory next to the
-`setup.cfg`, so subprojects were completely unsupported with those releases.
-
-### Editable installs with setuptools <= 18.5
-
-`setup.py develop` and `pip install --editable .` allow you to install a
-project into a virtualenv once, then continue editing the source code (and
-test) without re-installing after every change.
-
-"Entry-point scripts" (`setup(entry_points={"console_scripts": ..})`) are a
-convenient way to specify executable scripts that should be installed along
-with the python package.
-
-These both work as expected when using modern setuptools. When using
-setuptools-18.5 or earlier, however, certain operations will cause
-`pkg_resources.DistributionNotFound` errors when running the entrypoint
-script, which must be resolved by re-installing the package. This happens
-when the install happens with one version, then the egg_info data is
-regenerated while a different version is checked out. Many setup.py commands
-cause egg_info to be rebuilt (including `sdist`, `wheel`, and installing into
-a different virtualenv), so this can be surprising.
-
-[Bug #83](https://github.com/warner/python-versioneer/issues/83) describes
-this one, but upgrading to a newer version of setuptools should probably
-resolve it.
-
-### Unicode version strings
-
-While Versioneer works (and is continually tested) with both Python 2 and
-Python 3, it is not entirely consistent with bytes-vs-unicode distinctions.
-Newer releases probably generate unicode version strings on py2. It's not
-clear that this is wrong, but it may be surprising for applications when then
-write these strings to a network connection or include them in bytes-oriented
-APIs like cryptographic checksums.
-
-[Bug #71](https://github.com/warner/python-versioneer/issues/71) investigates
-this question.
-
-
-## Updating Versioneer
-
-To upgrade your project to a new release of Versioneer, do the following:
-
-* install the new Versioneer (`pip install -U versioneer` or equivalent)
-* edit `setup.cfg`, if necessary, to include any new configuration settings
-  indicated by the release notes. See [UPGRADING](./UPGRADING.md) for details.
-* re-run `versioneer install` in your source tree, to replace
-  `SRC/_version.py`
-* commit any changed files
-
-## Future Directions
-
-This tool is designed to make it easily extended to other version-control
-systems: all VCS-specific components are in separate directories like
-src/git/ . The top-level `versioneer.py` script is assembled from these
-components by running make-versioneer.py . In the future, make-versioneer.py
-will take a VCS name as an argument, and will construct a version of
-`versioneer.py` that is specific to the given VCS. It might also take the
-configuration arguments that are currently provided manually during
-installation by editing setup.py . Alternatively, it might go the other
-direction and include code from all supported VCS systems, reducing the
-number of intermediate scripts.
-
-
-## License
-
-To make Versioneer easier to embed, all its code is dedicated to the public
-domain. The `_version.py` that it creates is also in the public domain.
-Specifically, both are released under the Creative Commons "Public Domain
-Dedication" license (CC0-1.0), as described in
-https://creativecommons.org/publicdomain/zero/1.0/ .
-
-"""
-
-from __future__ import print_function
-try:
-    import configparser
-except ImportError:
-    import ConfigParser as configparser
-import errno
-import json
-import os
-import re
-import subprocess
-import sys
-
-
-class VersioneerConfig:
-    """Container for Versioneer configuration parameters."""
-
-
-def get_root():
-    """Get the project root directory.
-
-    We require that all commands are run from the project root, i.e. the
-    directory that contains setup.py, setup.cfg, and versioneer.py .
-    """
-    root = os.path.realpath(os.path.abspath(os.getcwd()))
-    setup_py = os.path.join(root, "setup.py")
-    versioneer_py = os.path.join(root, "versioneer.py")
-    if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)):
-        # allow 'python path/to/setup.py COMMAND'
-        root = os.path.dirname(os.path.realpath(os.path.abspath(sys.argv[0])))
-        setup_py = os.path.join(root, "setup.py")
-        versioneer_py = os.path.join(root, "versioneer.py")
-    if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)):
-        err = ("Versioneer was unable to run the project root directory. "
-               "Versioneer requires setup.py to be executed from "
-               "its immediate directory (like 'python setup.py COMMAND'), "
-               "or in a way that lets it use sys.argv[0] to find the root "
-               "(like 'python path/to/setup.py COMMAND').")
-        raise VersioneerBadRootError(err)
-    try:
-        # Certain runtime workflows (setup.py install/develop in a setuptools
-        # tree) execute all dependencies in a single python process, so
-        # "versioneer" may be imported multiple times, and python's shared
-        # module-import table will cache the first one. So we can't use
-        # os.path.dirname(__file__), as that will find whichever
-        # versioneer.py was first imported, even in later projects.
-        me = os.path.realpath(os.path.abspath(__file__))
-        me_dir = os.path.normcase(os.path.splitext(me)[0])
-        vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0])
-        if me_dir != vsr_dir:
-            print("Warning: build in %s is using versioneer.py from %s"
-                  % (os.path.dirname(me), versioneer_py))
-    except NameError:
-        pass
-    return root
-
-
-def get_config_from_root(root):
-    """Read the project setup.cfg file to determine Versioneer config."""
-    # This might raise EnvironmentError (if setup.cfg is missing), or
-    # configparser.NoSectionError (if it lacks a [versioneer] section), or
-    # configparser.NoOptionError (if it lacks "VCS="). See the docstring at
-    # the top of versioneer.py for instructions on writing your setup.cfg .
-    setup_cfg = os.path.join(root, "setup.cfg")
-    parser = configparser.ConfigParser()
-    parser.read(setup_cfg)
-    VCS = parser.get("versioneer", "VCS")  # mandatory
-
-    def get(parser, name):
-        if parser.has_option("versioneer", name):
-            return parser.get("versioneer", name)
-        return None
-    cfg = VersioneerConfig()
-    cfg.VCS = VCS
-    cfg.style = get(parser, "style") or ""
-    cfg.versionfile_source = get(parser, "versionfile_source")
-    cfg.versionfile_build = get(parser, "versionfile_build")
-    cfg.tag_prefix = get(parser, "tag_prefix")
-    if cfg.tag_prefix in ("''", '""'):
-        cfg.tag_prefix = ""
-    cfg.parentdir_prefix = get(parser, "parentdir_prefix")
-    cfg.verbose = get(parser, "verbose")
-    return cfg
-
-
-class NotThisMethod(Exception):
-    """Exception raised if a method is not valid for the current scenario."""
-
-
-# these dictionaries contain VCS-specific tools
-LONG_VERSION_PY = {}
-HANDLERS = {}
-
-
-def register_vcs_handler(vcs, method):  # decorator
-    """Decorator to mark a method as the handler for a particular VCS."""
-    def decorate(f):
-        """Store f in HANDLERS[vcs][method]."""
-        if vcs not in HANDLERS:
-            HANDLERS[vcs] = {}
-        HANDLERS[vcs][method] = f
-        return f
-    return decorate
-
-
-def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
-                env=None):
-    """Call the given command(s)."""
-    assert isinstance(commands, list)
-    p = None
-    for c in commands:
-        try:
-            dispcmd = str([c] + args)
-            # remember shell=False, so use git.cmd on windows, not just git
-            p = subprocess.Popen([c] + args, cwd=cwd, env=env,
-                                 stdout=subprocess.PIPE,
-                                 stderr=(subprocess.PIPE if hide_stderr
-                                         else None))
-            break
-        except EnvironmentError:
-            e = sys.exc_info()[1]
-            if e.errno == errno.ENOENT:
-                continue
-            if verbose:
-                print("unable to run %s" % dispcmd)
-                print(e)
-            return None, None
-    else:
-        if verbose:
-            print("unable to find command, tried %s" % (commands,))
-        return None, None
-    stdout = p.communicate()[0].strip()
-    if sys.version_info[0] >= 3:
-        stdout = stdout.decode()
-    if p.returncode != 0:
-        if verbose:
-            print("unable to run %s (error)" % dispcmd)
-            print("stdout was %s" % stdout)
-        return None, p.returncode
-    return stdout, p.returncode
-
-
-LONG_VERSION_PY['git'] = '''
-# This file helps to compute a version number in source trees obtained from
-# git-archive tarball (such as those provided by githubs download-from-tag
-# feature). Distribution tarballs (built by setup.py sdist) and build
-# directories (produced by setup.py build) will contain a much shorter file
-# that just contains the computed version number.
-
-# This file is released into the public domain. Generated by
-# versioneer-0.18 (https://github.com/warner/python-versioneer)
-
-"""Git implementation of _version.py."""
-
-import errno
-import os
-import re
-import subprocess
-import sys
-
-
-def get_keywords():
-    """Get the keywords needed to look up the version information."""
-    # these strings will be replaced by git during git-archive.
-    # setup.py/versioneer.py will grep for the variable names, so they must
-    # each be defined on a line of their own. _version.py will just call
-    # get_keywords().
-    git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s"
-    git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s"
-    git_date = "%(DOLLAR)sFormat:%%ci%(DOLLAR)s"
-    keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
-    return keywords
-
-
-class VersioneerConfig:
-    """Container for Versioneer configuration parameters."""
-
-
-def get_config():
-    """Create, populate and return the VersioneerConfig() object."""
-    # these strings are filled in when 'setup.py versioneer' creates
-    # _version.py
-    cfg = VersioneerConfig()
-    cfg.VCS = "git"
-    cfg.style = "%(STYLE)s"
-    cfg.tag_prefix = "%(TAG_PREFIX)s"
-    cfg.parentdir_prefix = "%(PARENTDIR_PREFIX)s"
-    cfg.versionfile_source = "%(VERSIONFILE_SOURCE)s"
-    cfg.verbose = False
-    return cfg
-
-
-class NotThisMethod(Exception):
-    """Exception raised if a method is not valid for the current scenario."""
-
-
-LONG_VERSION_PY = {}
-HANDLERS = {}
-
-
-def register_vcs_handler(vcs, method):  # decorator
-    """Decorator to mark a method as the handler for a particular VCS."""
-    def decorate(f):
-        """Store f in HANDLERS[vcs][method]."""
-        if vcs not in HANDLERS:
-            HANDLERS[vcs] = {}
-        HANDLERS[vcs][method] = f
-        return f
-    return decorate
-
-
-def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
-                env=None):
-    """Call the given command(s)."""
-    assert isinstance(commands, list)
-    p = None
-    for c in commands:
-        try:
-            dispcmd = str([c] + args)
-            # remember shell=False, so use git.cmd on windows, not just git
-            p = subprocess.Popen([c] + args, cwd=cwd, env=env,
-                                 stdout=subprocess.PIPE,
-                                 stderr=(subprocess.PIPE if hide_stderr
-                                         else None))
-            break
-        except EnvironmentError:
-            e = sys.exc_info()[1]
-            if e.errno == errno.ENOENT:
-                continue
-            if verbose:
-                print("unable to run %%s" %% dispcmd)
-                print(e)
-            return None, None
-    else:
-        if verbose:
-            print("unable to find command, tried %%s" %% (commands,))
-        return None, None
-    stdout = p.communicate()[0].strip()
-    if sys.version_info[0] >= 3:
-        stdout = stdout.decode()
-    if p.returncode != 0:
-        if verbose:
-            print("unable to run %%s (error)" %% dispcmd)
-            print("stdout was %%s" %% stdout)
-        return None, p.returncode
-    return stdout, p.returncode
-
-
-def versions_from_parentdir(parentdir_prefix, root, verbose):
-    """Try to determine the version from the parent directory name.
-
-    Source tarballs conventionally unpack into a directory that includes both
-    the project name and a version string. We will also support searching up
-    two directory levels for an appropriately named parent directory
-    """
-    rootdirs = []
-
-    for i in range(3):
-        dirname = os.path.basename(root)
-        if dirname.startswith(parentdir_prefix):
-            return {"version": dirname[len(parentdir_prefix):],
-                    "full-revisionid": None,
-                    "dirty": False, "error": None, "date": None}
-        else:
-            rootdirs.append(root)
-            root = os.path.dirname(root)  # up a level
-
-    if verbose:
-        print("Tried directories %%s but none started with prefix %%s" %%
-              (str(rootdirs), parentdir_prefix))
-    raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
-
-
-@register_vcs_handler("git", "get_keywords")
-def git_get_keywords(versionfile_abs):
-    """Extract version information from the given file."""
-    # the code embedded in _version.py can just fetch the value of these
-    # keywords. When used from setup.py, we don't want to import _version.py,
-    # so we do it with a regexp instead. This function is not used from
-    # _version.py.
-    keywords = {}
-    try:
-        f = open(versionfile_abs, "r")
-        for line in f.readlines():
-            if line.strip().startswith("git_refnames ="):
-                mo = re.search(r'=\s*"(.*)"', line)
-                if mo:
-                    keywords["refnames"] = mo.group(1)
-            if line.strip().startswith("git_full ="):
-                mo = re.search(r'=\s*"(.*)"', line)
-                if mo:
-                    keywords["full"] = mo.group(1)
-            if line.strip().startswith("git_date ="):
-                mo = re.search(r'=\s*"(.*)"', line)
-                if mo:
-                    keywords["date"] = mo.group(1)
-        f.close()
-    except EnvironmentError:
-        pass
-    return keywords
-
-
-@register_vcs_handler("git", "keywords")
-def git_versions_from_keywords(keywords, tag_prefix, verbose):
-    """Get version information from git keywords."""
-    if not keywords:
-        raise NotThisMethod("no keywords at all, weird")
-    date = keywords.get("date")
-    if date is not None:
-        # git-2.2.0 added "%%cI", which expands to an ISO-8601 -compliant
-        # datestamp. However we prefer "%%ci" (which expands to an "ISO-8601
-        # -like" string, which we must then edit to make compliant), because
-        # it's been around since git-1.5.3, and it's too difficult to
-        # discover which version we're using, or to work around using an
-        # older one.
-        date = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
-    refnames = keywords["refnames"].strip()
-    if refnames.startswith("$Format"):
-        if verbose:
-            print("keywords are unexpanded, not using")
-        raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
-    refs = set([r.strip() for r in refnames.strip("()").split(",")])
-    # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
-    # just "foo-1.0". If we see a "tag: " prefix, prefer those.
-    TAG = "tag: "
-    tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)])
-    if not tags:
-        # Either we're using git < 1.8.3, or there really are no tags. We use
-        # a heuristic: assume all version tags have a digit. The old git %%d
-        # expansion behaves like git log --decorate=short and strips out the
-        # refs/heads/ and refs/tags/ prefixes that would let us distinguish
-        # between branches and tags. By ignoring refnames without digits, we
-        # filter out many common branch names like "release" and
-        # "stabilization", as well as "HEAD" and "master".
-        tags = set([r for r in refs if re.search(r'\d', r)])
-        if verbose:
-            print("discarding '%%s', no digits" %% ",".join(refs - tags))
-    if verbose:
-        print("likely tags: %%s" %% ",".join(sorted(tags)))
-    for ref in sorted(tags):
-        # sorting will prefer e.g. "2.0" over "2.0rc1"
-        if ref.startswith(tag_prefix):
-            r = ref[len(tag_prefix):]
-            if verbose:
-                print("picking %%s" %% r)
-            return {"version": r,
-                    "full-revisionid": keywords["full"].strip(),
-                    "dirty": False, "error": None,
-                    "date": date}
-    # no suitable tags, so version is "0+unknown", but full hex is still there
-    if verbose:
-        print("no suitable tags, using unknown + full revision id")
-    return {"version": "0+unknown",
-            "full-revisionid": keywords["full"].strip(),
-            "dirty": False, "error": "no suitable tags", "date": None}
-
-
-@register_vcs_handler("git", "pieces_from_vcs")
-def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
-    """Get version from 'git describe' in the root of the source tree.
-
-    This only gets called if the git-archive 'subst' keywords were *not*
-    expanded, and _version.py hasn't already been rewritten with a short
-    version string, meaning we're inside a checked out source tree.
-    """
-    GITS = ["git"]
-    if sys.platform == "win32":
-        GITS = ["git.cmd", "git.exe"]
-
-    out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root,
-                          hide_stderr=True)
-    if rc != 0:
-        if verbose:
-            print("Directory %%s not under git control" %% root)
-        raise NotThisMethod("'git rev-parse --git-dir' returned error")
-
-    # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
-    # if there isn't one, this yields HEX[-dirty] (no NUM)
-    describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty",
-                                          "--always", "--long",
-                                          "--match", "%%s*" %% tag_prefix],
-                                   cwd=root)
-    # --long was added in git-1.5.5
-    if describe_out is None:
-        raise NotThisMethod("'git describe' failed")
-    describe_out = describe_out.strip()
-    full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root)
-    if full_out is None:
-        raise NotThisMethod("'git rev-parse' failed")
-    full_out = full_out.strip()
-
-    pieces = {}
-    pieces["long"] = full_out
-    pieces["short"] = full_out[:7]  # maybe improved later
-    pieces["error"] = None
-
-    # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
-    # TAG might have hyphens.
-    git_describe = describe_out
-
-    # look for -dirty suffix
-    dirty = git_describe.endswith("-dirty")
-    pieces["dirty"] = dirty
-    if dirty:
-        git_describe = git_describe[:git_describe.rindex("-dirty")]
-
-    # now we have TAG-NUM-gHEX or HEX
-
-    if "-" in git_describe:
-        # TAG-NUM-gHEX
-        mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
-        if not mo:
-            # unparseable. Maybe git-describe is misbehaving?
-            pieces["error"] = ("unable to parse git-describe output: '%%s'"
-                               %% describe_out)
-            return pieces
-
-        # tag
-        full_tag = mo.group(1)
-        if not full_tag.startswith(tag_prefix):
-            if verbose:
-                fmt = "tag '%%s' doesn't start with prefix '%%s'"
-                print(fmt %% (full_tag, tag_prefix))
-            pieces["error"] = ("tag '%%s' doesn't start with prefix '%%s'"
-                               %% (full_tag, tag_prefix))
-            return pieces
-        pieces["closest-tag"] = full_tag[len(tag_prefix):]
-
-        # distance: number of commits since tag
-        pieces["distance"] = int(mo.group(2))
-
-        # commit: short hex revision ID
-        pieces["short"] = mo.group(3)
-
-    else:
-        # HEX: no tags
-        pieces["closest-tag"] = None
-        count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"],
-                                    cwd=root)
-        pieces["distance"] = int(count_out)  # total number of commits
-
-    # commit date: see ISO-8601 comment in git_versions_from_keywords()
-    date = run_command(GITS, ["show", "-s", "--format=%%ci", "HEAD"],
-                       cwd=root)[0].strip()
-    pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
-
-    return pieces
-
-
-def plus_or_dot(pieces):
-    """Return a + if we don't already have one, else return a ."""
-    if "+" in pieces.get("closest-tag", ""):
-        return "."
-    return "+"
-
-
-def render_pep440(pieces):
-    """Build up version string, with post-release "local version identifier".
-
-    Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
-    get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty
-
-    Exceptions:
-    1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty]
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"] or pieces["dirty"]:
-            rendered += plus_or_dot(pieces)
-            rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"])
-            if pieces["dirty"]:
-                rendered += ".dirty"
-    else:
-        # exception #1
-        rendered = "0+untagged.%%d.g%%s" %% (pieces["distance"],
-                                          pieces["short"])
-        if pieces["dirty"]:
-            rendered += ".dirty"
-    return rendered
-
-
-def render_pep440_pre(pieces):
-    """TAG[.post.devDISTANCE] -- No -dirty.
-
-    Exceptions:
-    1: no tags. 0.post.devDISTANCE
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"]:
-            rendered += ".post.dev%%d" %% pieces["distance"]
-    else:
-        # exception #1
-        rendered = "0.post.dev%%d" %% pieces["distance"]
-    return rendered
-
-
-def render_pep440_post(pieces):
-    """TAG[.postDISTANCE[.dev0]+gHEX] .
-
-    The ".dev0" means dirty. Note that .dev0 sorts backwards
-    (a dirty tree will appear "older" than the corresponding clean one),
-    but you shouldn't be releasing software with -dirty anyways.
-
-    Exceptions:
-    1: no tags. 0.postDISTANCE[.dev0]
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"] or pieces["dirty"]:
-            rendered += ".post%%d" %% pieces["distance"]
-            if pieces["dirty"]:
-                rendered += ".dev0"
-            rendered += plus_or_dot(pieces)
-            rendered += "g%%s" %% pieces["short"]
-    else:
-        # exception #1
-        rendered = "0.post%%d" %% pieces["distance"]
-        if pieces["dirty"]:
-            rendered += ".dev0"
-        rendered += "+g%%s" %% pieces["short"]
-    return rendered
-
-
-def render_pep440_old(pieces):
-    """TAG[.postDISTANCE[.dev0]] .
-
-    The ".dev0" means dirty.
-
-    Eexceptions:
-    1: no tags. 0.postDISTANCE[.dev0]
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"] or pieces["dirty"]:
-            rendered += ".post%%d" %% pieces["distance"]
-            if pieces["dirty"]:
-                rendered += ".dev0"
-    else:
-        # exception #1
-        rendered = "0.post%%d" %% pieces["distance"]
-        if pieces["dirty"]:
-            rendered += ".dev0"
-    return rendered
-
-
-def render_git_describe(pieces):
-    """TAG[-DISTANCE-gHEX][-dirty].
-
-    Like 'git describe --tags --dirty --always'.
-
-    Exceptions:
-    1: no tags. HEX[-dirty]  (note: no 'g' prefix)
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"]:
-            rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"])
-    else:
-        # exception #1
-        rendered = pieces["short"]
-    if pieces["dirty"]:
-        rendered += "-dirty"
-    return rendered
-
-
-def render_git_describe_long(pieces):
-    """TAG-DISTANCE-gHEX[-dirty].
-
-    Like 'git describe --tags --dirty --always -long'.
-    The distance/hash is unconditional.
-
-    Exceptions:
-    1: no tags. HEX[-dirty]  (note: no 'g' prefix)
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"])
-    else:
-        # exception #1
-        rendered = pieces["short"]
-    if pieces["dirty"]:
-        rendered += "-dirty"
-    return rendered
-
-
-def render(pieces, style):
-    """Render the given version pieces into the requested style."""
-    if pieces["error"]:
-        return {"version": "unknown",
-                "full-revisionid": pieces.get("long"),
-                "dirty": None,
-                "error": pieces["error"],
-                "date": None}
-
-    if not style or style == "default":
-        style = "pep440"  # the default
-
-    if style == "pep440":
-        rendered = render_pep440(pieces)
-    elif style == "pep440-pre":
-        rendered = render_pep440_pre(pieces)
-    elif style == "pep440-post":
-        rendered = render_pep440_post(pieces)
-    elif style == "pep440-old":
-        rendered = render_pep440_old(pieces)
-    elif style == "git-describe":
-        rendered = render_git_describe(pieces)
-    elif style == "git-describe-long":
-        rendered = render_git_describe_long(pieces)
-    else:
-        raise ValueError("unknown style '%%s'" %% style)
-
-    return {"version": rendered, "full-revisionid": pieces["long"],
-            "dirty": pieces["dirty"], "error": None,
-            "date": pieces.get("date")}
-
-
-def get_versions():
-    """Get version information or return default if unable to do so."""
-    # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
-    # __file__, we can work backwards from there to the root. Some
-    # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which
-    # case we can only use expanded keywords.
-
-    cfg = get_config()
-    verbose = cfg.verbose
-
-    try:
-        return git_versions_from_keywords(get_keywords(), cfg.tag_prefix,
-                                          verbose)
-    except NotThisMethod:
-        pass
-
-    try:
-        root = os.path.realpath(__file__)
-        # versionfile_source is the relative path from the top of the source
-        # tree (where the .git directory might live) to this file. Invert
-        # this to find the root from __file__.
-        for i in cfg.versionfile_source.split('/'):
-            root = os.path.dirname(root)
-    except NameError:
-        return {"version": "0+unknown", "full-revisionid": None,
-                "dirty": None,
-                "error": "unable to find root of source tree",
-                "date": None}
-
-    try:
-        pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose)
-        return render(pieces, cfg.style)
-    except NotThisMethod:
-        pass
-
-    try:
-        if cfg.parentdir_prefix:
-            return versions_from_parentdir(cfg.parentdir_prefix, root, verbose)
-    except NotThisMethod:
-        pass
-
-    return {"version": "0+unknown", "full-revisionid": None,
-            "dirty": None,
-            "error": "unable to compute version", "date": None}
-'''
-
-
-@register_vcs_handler("git", "get_keywords")
-def git_get_keywords(versionfile_abs):
-    """Extract version information from the given file."""
-    # the code embedded in _version.py can just fetch the value of these
-    # keywords. When used from setup.py, we don't want to import _version.py,
-    # so we do it with a regexp instead. This function is not used from
-    # _version.py.
-    keywords = {}
-    try:
-        f = open(versionfile_abs, "r")
-        for line in f.readlines():
-            if line.strip().startswith("git_refnames ="):
-                mo = re.search(r'=\s*"(.*)"', line)
-                if mo:
-                    keywords["refnames"] = mo.group(1)
-            if line.strip().startswith("git_full ="):
-                mo = re.search(r'=\s*"(.*)"', line)
-                if mo:
-                    keywords["full"] = mo.group(1)
-            if line.strip().startswith("git_date ="):
-                mo = re.search(r'=\s*"(.*)"', line)
-                if mo:
-                    keywords["date"] = mo.group(1)
-        f.close()
-    except EnvironmentError:
-        pass
-    return keywords
-
-
-@register_vcs_handler("git", "keywords")
-def git_versions_from_keywords(keywords, tag_prefix, verbose):
-    """Get version information from git keywords."""
-    if not keywords:
-        raise NotThisMethod("no keywords at all, weird")
-    date = keywords.get("date")
-    if date is not None:
-        # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant
-        # datestamp. However we prefer "%ci" (which expands to an "ISO-8601
-        # -like" string, which we must then edit to make compliant), because
-        # it's been around since git-1.5.3, and it's too difficult to
-        # discover which version we're using, or to work around using an
-        # older one.
-        date = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
-    refnames = keywords["refnames"].strip()
-    if refnames.startswith("$Format"):
-        if verbose:
-            print("keywords are unexpanded, not using")
-        raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
-    refs = set([r.strip() for r in refnames.strip("()").split(",")])
-    # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
-    # just "foo-1.0". If we see a "tag: " prefix, prefer those.
-    TAG = "tag: "
-    tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)])
-    if not tags:
-        # Either we're using git < 1.8.3, or there really are no tags. We use
-        # a heuristic: assume all version tags have a digit. The old git %d
-        # expansion behaves like git log --decorate=short and strips out the
-        # refs/heads/ and refs/tags/ prefixes that would let us distinguish
-        # between branches and tags. By ignoring refnames without digits, we
-        # filter out many common branch names like "release" and
-        # "stabilization", as well as "HEAD" and "master".
-        tags = set([r for r in refs if re.search(r'\d', r)])
-        if verbose:
-            print("discarding '%s', no digits" % ",".join(refs - tags))
-    if verbose:
-        print("likely tags: %s" % ",".join(sorted(tags)))
-    for ref in sorted(tags):
-        # sorting will prefer e.g. "2.0" over "2.0rc1"
-        if ref.startswith(tag_prefix):
-            r = ref[len(tag_prefix):]
-            if verbose:
-                print("picking %s" % r)
-            return {"version": r,
-                    "full-revisionid": keywords["full"].strip(),
-                    "dirty": False, "error": None,
-                    "date": date}
-    # no suitable tags, so version is "0+unknown", but full hex is still there
-    if verbose:
-        print("no suitable tags, using unknown + full revision id")
-    return {"version": "0+unknown",
-            "full-revisionid": keywords["full"].strip(),
-            "dirty": False, "error": "no suitable tags", "date": None}
-
-
-@register_vcs_handler("git", "pieces_from_vcs")
-def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
-    """Get version from 'git describe' in the root of the source tree.
-
-    This only gets called if the git-archive 'subst' keywords were *not*
-    expanded, and _version.py hasn't already been rewritten with a short
-    version string, meaning we're inside a checked out source tree.
-    """
-    GITS = ["git"]
-    if sys.platform == "win32":
-        GITS = ["git.cmd", "git.exe"]
-
-    out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root,
-                          hide_stderr=True)
-    if rc != 0:
-        if verbose:
-            print("Directory %s not under git control" % root)
-        raise NotThisMethod("'git rev-parse --git-dir' returned error")
-
-    # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
-    # if there isn't one, this yields HEX[-dirty] (no NUM)
-    describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty",
-                                          "--always", "--long",
-                                          "--match", "%s*" % tag_prefix],
-                                   cwd=root)
-    # --long was added in git-1.5.5
-    if describe_out is None:
-        raise NotThisMethod("'git describe' failed")
-    describe_out = describe_out.strip()
-    full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root)
-    if full_out is None:
-        raise NotThisMethod("'git rev-parse' failed")
-    full_out = full_out.strip()
-
-    pieces = {}
-    pieces["long"] = full_out
-    pieces["short"] = full_out[:7]  # maybe improved later
-    pieces["error"] = None
-
-    # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
-    # TAG might have hyphens.
-    git_describe = describe_out
-
-    # look for -dirty suffix
-    dirty = git_describe.endswith("-dirty")
-    pieces["dirty"] = dirty
-    if dirty:
-        git_describe = git_describe[:git_describe.rindex("-dirty")]
-
-    # now we have TAG-NUM-gHEX or HEX
-
-    if "-" in git_describe:
-        # TAG-NUM-gHEX
-        mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
-        if not mo:
-            # unparseable. Maybe git-describe is misbehaving?
-            pieces["error"] = ("unable to parse git-describe output: '%s'"
-                               % describe_out)
-            return pieces
-
-        # tag
-        full_tag = mo.group(1)
-        if not full_tag.startswith(tag_prefix):
-            if verbose:
-                fmt = "tag '%s' doesn't start with prefix '%s'"
-                print(fmt % (full_tag, tag_prefix))
-            pieces["error"] = ("tag '%s' doesn't start with prefix '%s'"
-                               % (full_tag, tag_prefix))
-            return pieces
-        pieces["closest-tag"] = full_tag[len(tag_prefix):]
-
-        # distance: number of commits since tag
-        pieces["distance"] = int(mo.group(2))
-
-        # commit: short hex revision ID
-        pieces["short"] = mo.group(3)
-
-    else:
-        # HEX: no tags
-        pieces["closest-tag"] = None
-        count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"],
-                                    cwd=root)
-        pieces["distance"] = int(count_out)  # total number of commits
-
-    # commit date: see ISO-8601 comment in git_versions_from_keywords()
-    date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"],
-                       cwd=root)[0].strip()
-    pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
-
-    return pieces
-
-
-def do_vcs_install(manifest_in, versionfile_source, ipy):
-    """Git-specific installation logic for Versioneer.
-
-    For Git, this means creating/changing .gitattributes to mark _version.py
-    for export-subst keyword substitution.
-    """
-    GITS = ["git"]
-    if sys.platform == "win32":
-        GITS = ["git.cmd", "git.exe"]
-    files = [manifest_in, versionfile_source]
-    if ipy:
-        files.append(ipy)
-    try:
-        me = __file__
-        if me.endswith(".pyc") or me.endswith(".pyo"):
-            me = os.path.splitext(me)[0] + ".py"
-        versioneer_file = os.path.relpath(me)
-    except NameError:
-        versioneer_file = "versioneer.py"
-    files.append(versioneer_file)
-    present = False
-    try:
-        f = open(".gitattributes", "r")
-        for line in f.readlines():
-            if line.strip().startswith(versionfile_source):
-                if "export-subst" in line.strip().split()[1:]:
-                    present = True
-        f.close()
-    except EnvironmentError:
-        pass
-    if not present:
-        f = open(".gitattributes", "a+")
-        f.write("%s export-subst\n" % versionfile_source)
-        f.close()
-        files.append(".gitattributes")
-    run_command(GITS, ["add", "--"] + files)
-
-
-def versions_from_parentdir(parentdir_prefix, root, verbose):
-    """Try to determine the version from the parent directory name.
-
-    Source tarballs conventionally unpack into a directory that includes both
-    the project name and a version string. We will also support searching up
-    two directory levels for an appropriately named parent directory
-    """
-    rootdirs = []
-
-    for i in range(3):
-        dirname = os.path.basename(root)
-        if dirname.startswith(parentdir_prefix):
-            return {"version": dirname[len(parentdir_prefix):],
-                    "full-revisionid": None,
-                    "dirty": False, "error": None, "date": None}
-        else:
-            rootdirs.append(root)
-            root = os.path.dirname(root)  # up a level
-
-    if verbose:
-        print("Tried directories %s but none started with prefix %s" %
-              (str(rootdirs), parentdir_prefix))
-    raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
-
-
-SHORT_VERSION_PY = """
-# This file was generated by 'versioneer.py' (0.18) from
-# revision-control system data, or from the parent directory name of an
-# unpacked source archive. Distribution tarballs contain a pre-generated copy
-# of this file.
-
-import json
-
-version_json = '''
-%s
-'''  # END VERSION_JSON
-
-
-def get_versions():
-    return json.loads(version_json)
-"""
-
-
-def versions_from_file(filename):
-    """Try to determine the version from _version.py if present."""
-    try:
-        with open(filename) as f:
-            contents = f.read()
-    except EnvironmentError:
-        raise NotThisMethod("unable to read _version.py")
-    mo = re.search(r"version_json = '''\n(.*)'''  # END VERSION_JSON",
-                   contents, re.M | re.S)
-    if not mo:
-        mo = re.search(r"version_json = '''\r\n(.*)'''  # END VERSION_JSON",
-                       contents, re.M | re.S)
-    if not mo:
-        raise NotThisMethod("no version_json in _version.py")
-    return json.loads(mo.group(1))
-
-
-def write_to_version_file(filename, versions):
-    """Write the given version number to the given _version.py file."""
-    os.unlink(filename)
-    contents = json.dumps(versions, sort_keys=True,
-                          indent=1, separators=(",", ": "))
-    with open(filename, "w") as f:
-        f.write(SHORT_VERSION_PY % contents)
-
-    print("set %s to '%s'" % (filename, versions["version"]))
-
-
-def plus_or_dot(pieces):
-    """Return a + if we don't already have one, else return a ."""
-    if "+" in pieces.get("closest-tag", ""):
-        return "."
-    return "+"
-
-
-def render_pep440(pieces):
-    """Build up version string, with post-release "local version identifier".
-
-    Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
-    get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty
-
-    Exceptions:
-    1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty]
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"] or pieces["dirty"]:
-            rendered += plus_or_dot(pieces)
-            rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
-            if pieces["dirty"]:
-                rendered += ".dirty"
-    else:
-        # exception #1
-        rendered = "0+untagged.%d.g%s" % (pieces["distance"],
-                                          pieces["short"])
-        if pieces["dirty"]:
-            rendered += ".dirty"
-    return rendered
-
-
-def render_pep440_pre(pieces):
-    """TAG[.post.devDISTANCE] -- No -dirty.
-
-    Exceptions:
-    1: no tags. 0.post.devDISTANCE
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"]:
-            rendered += ".post.dev%d" % pieces["distance"]
-    else:
-        # exception #1
-        rendered = "0.post.dev%d" % pieces["distance"]
-    return rendered
-
-
-def render_pep440_post(pieces):
-    """TAG[.postDISTANCE[.dev0]+gHEX] .
-
-    The ".dev0" means dirty. Note that .dev0 sorts backwards
-    (a dirty tree will appear "older" than the corresponding clean one),
-    but you shouldn't be releasing software with -dirty anyways.
-
-    Exceptions:
-    1: no tags. 0.postDISTANCE[.dev0]
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"] or pieces["dirty"]:
-            rendered += ".post%d" % pieces["distance"]
-            if pieces["dirty"]:
-                rendered += ".dev0"
-            rendered += plus_or_dot(pieces)
-            rendered += "g%s" % pieces["short"]
-    else:
-        # exception #1
-        rendered = "0.post%d" % pieces["distance"]
-        if pieces["dirty"]:
-            rendered += ".dev0"
-        rendered += "+g%s" % pieces["short"]
-    return rendered
-
-
-def render_pep440_old(pieces):
-    """TAG[.postDISTANCE[.dev0]] .
-
-    The ".dev0" means dirty.
-
-    Eexceptions:
-    1: no tags. 0.postDISTANCE[.dev0]
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"] or pieces["dirty"]:
-            rendered += ".post%d" % pieces["distance"]
-            if pieces["dirty"]:
-                rendered += ".dev0"
-    else:
-        # exception #1
-        rendered = "0.post%d" % pieces["distance"]
-        if pieces["dirty"]:
-            rendered += ".dev0"
-    return rendered
-
-
-def render_git_describe(pieces):
-    """TAG[-DISTANCE-gHEX][-dirty].
-
-    Like 'git describe --tags --dirty --always'.
-
-    Exceptions:
-    1: no tags. HEX[-dirty]  (note: no 'g' prefix)
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"]:
-            rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
-    else:
-        # exception #1
-        rendered = pieces["short"]
-    if pieces["dirty"]:
-        rendered += "-dirty"
-    return rendered
-
-
-def render_git_describe_long(pieces):
-    """TAG-DISTANCE-gHEX[-dirty].
-
-    Like 'git describe --tags --dirty --always -long'.
-    The distance/hash is unconditional.
-
-    Exceptions:
-    1: no tags. HEX[-dirty]  (note: no 'g' prefix)
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
-    else:
-        # exception #1
-        rendered = pieces["short"]
-    if pieces["dirty"]:
-        rendered += "-dirty"
-    return rendered
-
-
-def render(pieces, style):
-    """Render the given version pieces into the requested style."""
-    if pieces["error"]:
-        return {"version": "unknown",
-                "full-revisionid": pieces.get("long"),
-                "dirty": None,
-                "error": pieces["error"],
-                "date": None}
-
-    if not style or style == "default":
-        style = "pep440"  # the default
-
-    if style == "pep440":
-        rendered = render_pep440(pieces)
-    elif style == "pep440-pre":
-        rendered = render_pep440_pre(pieces)
-    elif style == "pep440-post":
-        rendered = render_pep440_post(pieces)
-    elif style == "pep440-old":
-        rendered = render_pep440_old(pieces)
-    elif style == "git-describe":
-        rendered = render_git_describe(pieces)
-    elif style == "git-describe-long":
-        rendered = render_git_describe_long(pieces)
-    else:
-        raise ValueError("unknown style '%s'" % style)
-
-    return {"version": rendered, "full-revisionid": pieces["long"],
-            "dirty": pieces["dirty"], "error": None,
-            "date": pieces.get("date")}
-
-
-class VersioneerBadRootError(Exception):
-    """The project root directory is unknown or missing key files."""
-
-
-def get_versions(verbose=False):
-    """Get the project version from whatever source is available.
-
-    Returns dict with two keys: 'version' and 'full'.
-    """
-    if "versioneer" in sys.modules:
-        # see the discussion in cmdclass.py:get_cmdclass()
-        del sys.modules["versioneer"]
-
-    root = get_root()
-    cfg = get_config_from_root(root)
-
-    assert cfg.VCS is not None, "please set [versioneer]VCS= in setup.cfg"
-    handlers = HANDLERS.get(cfg.VCS)
-    assert handlers, "unrecognized VCS '%s'" % cfg.VCS
-    verbose = verbose or cfg.verbose
-    assert cfg.versionfile_source is not None, \
-        "please set versioneer.versionfile_source"
-    assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix"
-
-    versionfile_abs = os.path.join(root, cfg.versionfile_source)
-
-    # extract version from first of: _version.py, VCS command (e.g. 'git
-    # describe'), parentdir. This is meant to work for developers using a
-    # source checkout, for users of a tarball created by 'setup.py sdist',
-    # and for users of a tarball/zipball created by 'git archive' or github's
-    # download-from-tag feature or the equivalent in other VCSes.
-
-    get_keywords_f = handlers.get("get_keywords")
-    from_keywords_f = handlers.get("keywords")
-    if get_keywords_f and from_keywords_f:
-        try:
-            keywords = get_keywords_f(versionfile_abs)
-            ver = from_keywords_f(keywords, cfg.tag_prefix, verbose)
-            if verbose:
-                print("got version from expanded keyword %s" % ver)
-            return ver
-        except NotThisMethod:
-            pass
-
-    try:
-        ver = versions_from_file(versionfile_abs)
-        if verbose:
-            print("got version from file %s %s" % (versionfile_abs, ver))
-        return ver
-    except NotThisMethod:
-        pass
-
-    from_vcs_f = handlers.get("pieces_from_vcs")
-    if from_vcs_f:
-        try:
-            pieces = from_vcs_f(cfg.tag_prefix, root, verbose)
-            ver = render(pieces, cfg.style)
-            if verbose:
-                print("got version from VCS %s" % ver)
-            return ver
-        except NotThisMethod:
-            pass
-
-    try:
-        if cfg.parentdir_prefix:
-            ver = versions_from_parentdir(cfg.parentdir_prefix, root, verbose)
-            if verbose:
-                print("got version from parentdir %s" % ver)
-            return ver
-    except NotThisMethod:
-        pass
-
-    if verbose:
-        print("unable to compute version")
-
-    return {"version": "0+unknown", "full-revisionid": None,
-            "dirty": None, "error": "unable to compute version",
-            "date": None}
-
-
-def get_version():
-    """Get the short version string for this project."""
-    return get_versions()["version"]
-
-
-def get_cmdclass():
-    """Get the custom setuptools/distutils subclasses used by Versioneer."""
-    if "versioneer" in sys.modules:
-        del sys.modules["versioneer"]
-        # this fixes the "python setup.py develop" case (also 'install' and
-        # 'easy_install .'), in which subdependencies of the main project are
-        # built (using setup.py bdist_egg) in the same python process. Assume
-        # a main project A and a dependency B, which use different versions
-        # of Versioneer. A's setup.py imports A's Versioneer, leaving it in
-        # sys.modules by the time B's setup.py is executed, causing B to run
-        # with the wrong versioneer. Setuptools wraps the sub-dep builds in a
-        # sandbox that restores sys.modules to it's pre-build state, so the
-        # parent is protected against the child's "import versioneer". By
-        # removing ourselves from sys.modules here, before the child build
-        # happens, we protect the child from the parent's versioneer too.
-        # Also see https://github.com/warner/python-versioneer/issues/52
-
-    cmds = {}
-
-    # we add "version" to both distutils and setuptools
-    from distutils.core import Command
-
-    class cmd_version(Command):
-        description = "report generated version string"
-        user_options = []
-        boolean_options = []
-
-        def initialize_options(self):
-            pass
-
-        def finalize_options(self):
-            pass
-
-        def run(self):
-            vers = get_versions(verbose=True)
-            print("Version: %s" % vers["version"])
-            print(" full-revisionid: %s" % vers.get("full-revisionid"))
-            print(" dirty: %s" % vers.get("dirty"))
-            print(" date: %s" % vers.get("date"))
-            if vers["error"]:
-                print(" error: %s" % vers["error"])
-    cmds["version"] = cmd_version
-
-    # we override "build_py" in both distutils and setuptools
-    #
-    # most invocation pathways end up running build_py:
-    #  distutils/build -> build_py
-    #  distutils/install -> distutils/build ->..
-    #  setuptools/bdist_wheel -> distutils/install ->..
-    #  setuptools/bdist_egg -> distutils/install_lib -> build_py
-    #  setuptools/install -> bdist_egg ->..
-    #  setuptools/develop -> ?
-    #  pip install:
-    #   copies source tree to a tempdir before running egg_info/etc
-    #   if .git isn't copied too, 'git describe' will fail
-    #   then does setup.py bdist_wheel, or sometimes setup.py install
-    #  setup.py egg_info -> ?
-
-    # we override different "build_py" commands for both environments
-    if "setuptools" in sys.modules:
-        from setuptools.command.build_py import build_py as _build_py
-    else:
-        from distutils.command.build_py import build_py as _build_py
-
-    class cmd_build_py(_build_py):
-        def run(self):
-            root = get_root()
-            cfg = get_config_from_root(root)
-            versions = get_versions()
-            _build_py.run(self)
-            # now locate _version.py in the new build/ directory and replace
-            # it with an updated value
-            if cfg.versionfile_build:
-                target_versionfile = os.path.join(self.build_lib,
-                                                  cfg.versionfile_build)
-                print("UPDATING %s" % target_versionfile)
-                write_to_version_file(target_versionfile, versions)
-    cmds["build_py"] = cmd_build_py
-
-    if "cx_Freeze" in sys.modules:  # cx_freeze enabled?
-        from cx_Freeze.dist import build_exe as _build_exe
-        # nczeczulin reports that py2exe won't like the pep440-style string
-        # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g.
-        # setup(console=[{
-        #   "version": versioneer.get_version().split("+", 1)[0], # FILEVERSION
-        #   "product_version": versioneer.get_version(),
-        #   ...
-
-        class cmd_build_exe(_build_exe):
-            def run(self):
-                root = get_root()
-                cfg = get_config_from_root(root)
-                versions = get_versions()
-                target_versionfile = cfg.versionfile_source
-                print("UPDATING %s" % target_versionfile)
-                write_to_version_file(target_versionfile, versions)
-
-                _build_exe.run(self)
-                os.unlink(target_versionfile)
-                with open(cfg.versionfile_source, "w") as f:
-                    LONG = LONG_VERSION_PY[cfg.VCS]
-                    f.write(LONG %
-                            {"DOLLAR": "$",
-                             "STYLE": cfg.style,
-                             "TAG_PREFIX": cfg.tag_prefix,
-                             "PARENTDIR_PREFIX": cfg.parentdir_prefix,
-                             "VERSIONFILE_SOURCE": cfg.versionfile_source,
-                             })
-        cmds["build_exe"] = cmd_build_exe
-        del cmds["build_py"]
-
-    if 'py2exe' in sys.modules:  # py2exe enabled?
-        try:
-            from py2exe.distutils_buildexe import py2exe as _py2exe  # py3
-        except ImportError:
-            from py2exe.build_exe import py2exe as _py2exe  # py2
-
-        class cmd_py2exe(_py2exe):
-            def run(self):
-                root = get_root()
-                cfg = get_config_from_root(root)
-                versions = get_versions()
-                target_versionfile = cfg.versionfile_source
-                print("UPDATING %s" % target_versionfile)
-                write_to_version_file(target_versionfile, versions)
-
-                _py2exe.run(self)
-                os.unlink(target_versionfile)
-                with open(cfg.versionfile_source, "w") as f:
-                    LONG = LONG_VERSION_PY[cfg.VCS]
-                    f.write(LONG %
-                            {"DOLLAR": "$",
-                             "STYLE": cfg.style,
-                             "TAG_PREFIX": cfg.tag_prefix,
-                             "PARENTDIR_PREFIX": cfg.parentdir_prefix,
-                             "VERSIONFILE_SOURCE": cfg.versionfile_source,
-                             })
-        cmds["py2exe"] = cmd_py2exe
-
-    # we override different "sdist" commands for both environments
-    if "setuptools" in sys.modules:
-        from setuptools.command.sdist import sdist as _sdist
-    else:
-        from distutils.command.sdist import sdist as _sdist
-
-    class cmd_sdist(_sdist):
-        def run(self):
-            versions = get_versions()
-            self._versioneer_generated_versions = versions
-            # unless we update this, the command will keep using the old
-            # version
-            self.distribution.metadata.version = versions["version"]
-            return _sdist.run(self)
-
-        def make_release_tree(self, base_dir, files):
-            root = get_root()
-            cfg = get_config_from_root(root)
-            _sdist.make_release_tree(self, base_dir, files)
-            # now locate _version.py in the new base_dir directory
-            # (remembering that it may be a hardlink) and replace it with an
-            # updated value
-            target_versionfile = os.path.join(base_dir, cfg.versionfile_source)
-            print("UPDATING %s" % target_versionfile)
-            write_to_version_file(target_versionfile,
-                                  self._versioneer_generated_versions)
-    cmds["sdist"] = cmd_sdist
-
-    return cmds
-
-
-CONFIG_ERROR = """
-setup.cfg is missing the necessary Versioneer configuration. You need
-a section like:
-
- [versioneer]
- VCS = git
- style = pep440
- versionfile_source = src/myproject/_version.py
- versionfile_build = myproject/_version.py
- tag_prefix =
- parentdir_prefix = myproject-
-
-You will also need to edit your setup.py to use the results:
-
- import versioneer
- setup(version=versioneer.get_version(),
-       cmdclass=versioneer.get_cmdclass(), ...)
-
-Please read the docstring in ./versioneer.py for configuration instructions,
-edit setup.cfg, and re-run the installer or 'python versioneer.py setup'.
-"""
-
-SAMPLE_CONFIG = """
-# See the docstring in versioneer.py for instructions. Note that you must
-# re-run 'versioneer.py setup' after changing this section, and commit the
-# resulting files.
-
-[versioneer]
-#VCS = git
-#style = pep440
-#versionfile_source =
-#versionfile_build =
-#tag_prefix =
-#parentdir_prefix =
-
-"""
-
-INIT_PY_SNIPPET = """
-from ._version import get_versions
-__version__ = get_versions()['version']
-del get_versions
-"""
-
-
-def do_setup():
-    """Main VCS-independent setup function for installing Versioneer."""
-    root = get_root()
-    try:
-        cfg = get_config_from_root(root)
-    except (EnvironmentError, configparser.NoSectionError,
-            configparser.NoOptionError) as e:
-        if isinstance(e, (EnvironmentError, configparser.NoSectionError)):
-            print("Adding sample versioneer config to setup.cfg",
-                  file=sys.stderr)
-            with open(os.path.join(root, "setup.cfg"), "a") as f:
-                f.write(SAMPLE_CONFIG)
-        print(CONFIG_ERROR, file=sys.stderr)
-        return 1
-
-    print(" creating %s" % cfg.versionfile_source)
-    with open(cfg.versionfile_source, "w") as f:
-        LONG = LONG_VERSION_PY[cfg.VCS]
-        f.write(LONG % {"DOLLAR": "$",
-                        "STYLE": cfg.style,
-                        "TAG_PREFIX": cfg.tag_prefix,
-                        "PARENTDIR_PREFIX": cfg.parentdir_prefix,
-                        "VERSIONFILE_SOURCE": cfg.versionfile_source,
-                        })
-
-    ipy = os.path.join(os.path.dirname(cfg.versionfile_source),
-                       "__init__.py")
-    if os.path.exists(ipy):
-        try:
-            with open(ipy, "r") as f:
-                old = f.read()
-        except EnvironmentError:
-            old = ""
-        if INIT_PY_SNIPPET not in old:
-            print(" appending to %s" % ipy)
-            with open(ipy, "a") as f:
-                f.write(INIT_PY_SNIPPET)
-        else:
-            print(" %s unmodified" % ipy)
-    else:
-        print(" %s doesn't exist, ok" % ipy)
-        ipy = None
-
-    # Make sure both the top-level "versioneer.py" and versionfile_source
-    # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so
-    # they'll be copied into source distributions. Pip won't be able to
-    # install the package without this.
-    manifest_in = os.path.join(root, "MANIFEST.in")
-    simple_includes = set()
-    try:
-        with open(manifest_in, "r") as f:
-            for line in f:
-                if line.startswith("include "):
-                    for include in line.split()[1:]:
-                        simple_includes.add(include)
-    except EnvironmentError:
-        pass
-    # That doesn't cover everything MANIFEST.in can do
-    # (http://docs.python.org/2/distutils/sourcedist.html#commands), so
-    # it might give some false negatives. Appending redundant 'include'
-    # lines is safe, though.
-    if "versioneer.py" not in simple_includes:
-        print(" appending 'versioneer.py' to MANIFEST.in")
-        with open(manifest_in, "a") as f:
-            f.write("include versioneer.py\n")
-    else:
-        print(" 'versioneer.py' already in MANIFEST.in")
-    if cfg.versionfile_source not in simple_includes:
-        print(" appending versionfile_source ('%s') to MANIFEST.in" %
-              cfg.versionfile_source)
-        with open(manifest_in, "a") as f:
-            f.write("include %s\n" % cfg.versionfile_source)
-    else:
-        print(" versionfile_source already in MANIFEST.in")
-
-    # Make VCS-specific changes. For git, this means creating/changing
-    # .gitattributes to mark _version.py for export-subst keyword
-    # substitution.
-    do_vcs_install(manifest_in, cfg.versionfile_source, ipy)
-    return 0
-
-
-def scan_setup_py():
-    """Validate the contents of setup.py against Versioneer's expectations."""
-    found = set()
-    setters = False
-    errors = 0
-    with open("setup.py", "r") as f:
-        for line in f.readlines():
-            if "import versioneer" in line:
-                found.add("import")
-            if "versioneer.get_cmdclass()" in line:
-                found.add("cmdclass")
-            if "versioneer.get_version()" in line:
-                found.add("get_version")
-            if "versioneer.VCS" in line:
-                setters = True
-            if "versioneer.versionfile_source" in line:
-                setters = True
-    if len(found) != 3:
-        print("")
-        print("Your setup.py appears to be missing some important items")
-        print("(but I might be wrong). Please make sure it has something")
-        print("roughly like the following:")
-        print("")
-        print(" import versioneer")
-        print(" setup( version=versioneer.get_version(),")
-        print("        cmdclass=versioneer.get_cmdclass(),  ...)")
-        print("")
-        errors += 1
-    if setters:
-        print("You should remove lines like 'versioneer.VCS = ' and")
-        print("'versioneer.versionfile_source = ' . This configuration")
-        print("now lives in setup.cfg, and should be removed from setup.py")
-        print("")
-        errors += 1
-    return errors
-
-
-if __name__ == "__main__":
-    cmd = sys.argv[1]
-    if cmd == "setup":
-        errors = do_setup()
-        errors += scan_setup_py()
-        if errors:
-            sys.exit(1)


Test patch:
diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml
deleted file mode 100644
index dd154d4ac..000000000
--- a/.github/workflows/testing.yml
+++ /dev/null
@@ -1,55 +0,0 @@
-name: Unit Tests
-
-on:
-  push:
-  pull_request:
-  schedule:
-      - cron: '00 4 * * *'  # daily at 4AM
-
-jobs:
-  build:
-
-    runs-on: ubuntu-latest
-    strategy:
-      matrix:
-        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
-        # We still test against jsonschema 2.x because pip's dependency resolver
-        # very often gives us 2.x even though we ask for 3.x. The event-model
-        # code has codepaths for either, conditional on what is installed.
-        # See https://github.com/bluesky/event-model/pull/175 and
-        # https://github.com/bluesky/event-model/issues/145 for more context.
-        jsonschema-version: [2, 3]
-      fail-fast: false
-    steps:
-
-    - uses: actions/checkout@v2
-
-    - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v2
-      with:
-        python-version: ${{ matrix.python-version }}
-
-    - name: Install
-      shell: bash -l {0}
-      run: source continuous_integration/scripts/install.sh
-
-    - name: Install test requirements
-      shell: bash -l {0}
-      run: |
-        set -vxeuo pipefail
-        python -m pip install -r requirements-dev.txt
-        python -m pip list
-
-    - name: Pin jsonschema
-      shell: bash -l {0}
-      run: |
-        set -vxeuo pipefail
-        python -m pip install --upgrade "jsonschema==${{ matrix.jsonschema-version }}.*"
-        python -m pip list
-
-    - name: Test with pytest
-      shell: bash -l {0}
-      run: |
-        set -vxeuo pipefail
-        coverage run -m pytest -v
-        coverage report
diff --git a/docs/developer/how-to/run-tests.rst b/docs/developer/how-to/run-tests.rst
new file mode 100644
index 000000000..d2e03644c
--- /dev/null
+++ b/docs/developer/how-to/run-tests.rst
@@ -0,0 +1,12 @@
+Run the tests using pytest
+==========================
+
+Testing is done with pytest_. It will find functions in the project that `look
+like tests`_, and run them to check for errors. You can run it with::
+
+    $ tox -e pytest
+
+It will also report coverage to the commandline and to ``cov.xml``.
+
+.. _pytest: https://pytest.org/
+.. _look like tests: https://docs.pytest.org/explanation/goodpractices.html#test-discovery
diff --git a/event_model/tests/test_auth.py b/event_model/tests/test_auth.py
index 54106b5a0..0f6b1e89b 100644
--- a/event_model/tests/test_auth.py
+++ b/event_model/tests/test_auth.py
@@ -1,16 +1,23 @@
-import event_model
 import pytest
 from jsonschema.exceptions import ValidationError
 
+import event_model
+
 
 def test_data_session():
-    run_bundle = event_model.compose_run(uid="42", metadata={"data_groups": ["a", "b"],
-                                                             "data_session": "magrathia_visit_42"})
+    run_bundle = event_model.compose_run(
+        uid="42",
+        metadata={"data_groups": ["a", "b"], "data_session": "magrathia_visit_42"},
+    )
     start_doc = run_bundle.start_doc
-    assert start_doc["data_groups"] == ['a', 'b']
+    assert start_doc["data_groups"] == ["a", "b"]
 
     with pytest.raises(ValidationError):
-        event_model.compose_run(uid="42", metadata={"data_session": ["shoud", "not", "be", "a", "list"]})
+        event_model.compose_run(
+            uid="42", metadata={"data_session": ["shoud", "not", "be", "a", "list"]}
+        )
 
     with pytest.raises(ValidationError):
-        event_model.compose_run(uid="42", metadata={"data_groups": "this should be an array"})
+        event_model.compose_run(
+            uid="42", metadata={"data_groups": "this should be an array"}
+        )
diff --git a/event_model/tests/test_em.py b/event_model/tests/test_em.py
index 753aa7eea..899d9d472 100644
--- a/event_model/tests/test_em.py
+++ b/event_model/tests/test_em.py
@@ -1,22 +1,32 @@
-from distutils.version import LooseVersion
 import json
 import pickle
+from distutils.version import LooseVersion
 
-import event_model
 import jsonschema
 import numpy
 import pytest
 
+import event_model
 
 JSONSCHEMA_2 = LooseVersion(jsonschema.__version__) < LooseVersion("3.0.0")
 
 
 def test_documents():
     dn = event_model.DocumentNames
-    for k in ('stop', 'start', 'descriptor',
-              'event', 'bulk_events', 'datum',
-              'resource', 'bulk_datum', 'event_page', 'datum_page',
-              'stream_resource', 'stream_datum'):
+    for k in (
+        "stop",
+        "start",
+        "descriptor",
+        "event",
+        "bulk_events",
+        "datum",
+        "resource",
+        "bulk_datum",
+        "event_page",
+        "datum_page",
+        "stream_resource",
+        "stream_datum",
+    ):
         assert dn(k) == getattr(dn, k)
 
 
@@ -48,38 +58,47 @@ def test_compose_run():
     assert bundle.compose_resource is compose_resource
     assert bundle.compose_stop is compose_stop
     bundle = compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'},
-                   'image': {'shape': [512, 512], 'dtype': 'number',
-                             'source': '...', 'external': 'FILESTORE:'}},
-        name='primary')
+        data_keys={
+            "motor": {"shape": [], "dtype": "number", "source": "..."},
+            "image": {
+                "shape": [512, 512],
+                "dtype": "number",
+                "source": "...",
+                "external": "FILESTORE:",
+            },
+        },
+        name="primary",
+    )
     descriptor_doc, compose_event, compose_event_page = bundle
     assert bundle.descriptor_doc is descriptor_doc
     assert bundle.compose_event is compose_event
     assert bundle.compose_event_page is compose_event_page
     bundle = compose_resource(
-        spec='TIFF', root='/tmp', resource_path='stack.tiff',
-        resource_kwargs={})
+        spec="TIFF", root="/tmp", resource_path="stack.tiff", resource_kwargs={}
+    )
     resource_doc, compose_datum, compose_datum_page = bundle
     assert bundle.resource_doc is resource_doc
     assert bundle.compose_datum is compose_datum
     assert bundle.compose_datum_page is compose_datum_page
-    datum_doc = compose_datum(datum_kwargs={'slice': 5})
+    datum_doc = compose_datum(datum_kwargs={"slice": 5})
     event_doc = compose_event(
-        data={'motor': 0, 'image': datum_doc['datum_id']},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': False})
-    datum_page = compose_datum_page(datum_kwargs={'slice': [10, 15]})
-    event_page = compose_event_page(data={'motor': [1, 2], 'image':
-                                          datum_page['datum_id']},
-                                    timestamps={'motor': [0, 0],
-                                                'image': [0, 0]},
-                                    filled={'image': [False, False]},
-                                    seq_num=[1, 2])
-    assert 'descriptor' in event_doc
-    assert 'descriptor' in event_page
-    assert event_doc['seq_num'] == 1
+        data={"motor": 0, "image": datum_doc["datum_id"]},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": False},
+    )
+    datum_page = compose_datum_page(datum_kwargs={"slice": [10, 15]})
+    event_page = compose_event_page(
+        data={"motor": [1, 2], "image": datum_page["datum_id"]},
+        timestamps={"motor": [0, 0], "image": [0, 0]},
+        filled={"image": [False, False]},
+        seq_num=[1, 2],
+    )
+    assert "descriptor" in event_doc
+    assert "descriptor" in event_page
+    assert event_doc["seq_num"] == 1
     stop_doc = compose_stop()
-    assert 'primary' in stop_doc['num_events']
-    assert stop_doc['num_events']['primary'] == 3
+    assert "primary" in stop_doc["num_events"]
+    assert stop_doc["num_events"]["primary"] == 3
 
 
 def test_compose_stream_resource(tmp_path):
@@ -91,100 +110,118 @@ def test_compose_stream_resource(tmp_path):
     compose_stream_resource = bundle.compose_stream_resource
     assert bundle.compose_stream_resource is compose_stream_resource
     stream_names = ["stream_1", "stream_2"]
-    bundle = compose_stream_resource(spec="TIFF_STREAM", root=str(tmp_path), resource_path="test_streams",
-                                     resource_kwargs={}, stream_names=stream_names)
+    bundle = compose_stream_resource(
+        spec="TIFF_STREAM",
+        root=str(tmp_path),
+        resource_path="test_streams",
+        resource_kwargs={},
+        stream_names=stream_names,
+    )
     resource_doc, compose_stream_data = bundle
     assert bundle.stream_resource_doc is resource_doc
     assert bundle.compose_stream_data is compose_stream_data
     assert compose_stream_data[0] is not compose_stream_data[1]
-    datum_doc_0, datum_doc_1 = (compose_stream_datum(datum_kwargs={})
-                                for compose_stream_datum in compose_stream_data)
+    datum_doc_0, datum_doc_1 = (
+        compose_stream_datum(datum_kwargs={})
+        for compose_stream_datum in compose_stream_data
+    )
     # Ensure independent counters
-    assert datum_doc_0['block_idx'] == datum_doc_1["block_idx"]
+    assert datum_doc_0["block_idx"] == datum_doc_1["block_idx"]
     datum_doc_1a = compose_stream_data[1](datum_kwargs={})
     assert datum_doc_1a["block_idx"] != datum_doc_1["block_idx"]
 
     # Ensure safety check
     from itertools import count
+
     with pytest.raises(KeyError):
-        event_model.compose_stream_datum(stream_resource=resource_doc, stream_name="stream_NA",
-                                         counter=count(), datum_kwargs={})
+        event_model.compose_stream_datum(
+            stream_resource=resource_doc,
+            stream_name="stream_NA",
+            counter=count(),
+            datum_kwargs={},
+        )
 
 
 def test_round_trip_pagination():
     run_bundle = event_model.compose_run()
     desc_bundle = run_bundle.compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'},
-                   'image': {'shape': [512, 512], 'dtype': 'number',
-                             'source': '...', 'external': 'FILESTORE:'}},
-        name='primary')
+        data_keys={
+            "motor": {"shape": [], "dtype": "number", "source": "..."},
+            "image": {
+                "shape": [512, 512],
+                "dtype": "number",
+                "source": "...",
+                "external": "FILESTORE:",
+            },
+        },
+        name="primary",
+    )
     res_bundle = run_bundle.compose_resource(
-        spec='TIFF', root='/tmp', resource_path='stack.tiff',
-        resource_kwargs={})
-    datum_doc1 = res_bundle.compose_datum(datum_kwargs={'slice': 5})
-    datum_doc2 = res_bundle.compose_datum(datum_kwargs={'slice': 10})
-    datum_doc3 = res_bundle.compose_datum(datum_kwargs={'slice': 15})
+        spec="TIFF", root="/tmp", resource_path="stack.tiff", resource_kwargs={}
+    )
+    datum_doc1 = res_bundle.compose_datum(datum_kwargs={"slice": 5})
+    datum_doc2 = res_bundle.compose_datum(datum_kwargs={"slice": 10})
+    datum_doc3 = res_bundle.compose_datum(datum_kwargs={"slice": 15})
     event_doc1 = desc_bundle.compose_event(
-        data={'motor': 0, 'image': datum_doc1['datum_id']},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': False},
-        seq_num=1)
+        data={"motor": 0, "image": datum_doc1["datum_id"]},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": False},
+        seq_num=1,
+    )
     event_doc2 = desc_bundle.compose_event(
-        data={'motor': 1, 'image': datum_doc2['datum_id']},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': False},
-        seq_num=1)
+        data={"motor": 1, "image": datum_doc2["datum_id"]},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": False},
+        seq_num=1,
+    )
     event_doc3 = desc_bundle.compose_event(
-        data={'motor': 2, 'image': datum_doc3['datum_id']},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': False},
-        seq_num=1)
+        data={"motor": 2, "image": datum_doc3["datum_id"]},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": False},
+        seq_num=1,
+    )
 
     # Round trip single event -> event_page -> event.
     expected = event_doc1
-    actual, = event_model.unpack_event_page(
-        event_model.pack_event_page(expected))
+    (actual,) = event_model.unpack_event_page(event_model.pack_event_page(expected))
     assert actual == expected
 
     # Round trip two events -> event_page -> events.
     expected = [event_doc1, event_doc2]
-    actual = list(event_model.unpack_event_page(
-        event_model.pack_event_page(*expected)))
+    actual = list(event_model.unpack_event_page(event_model.pack_event_page(*expected)))
     assert actual == expected
 
     # Round trip three events -> event_page -> events.
     expected = [event_doc1, event_doc2, event_doc3]
-    actual = list(event_model.unpack_event_page(
-        event_model.pack_event_page(*expected)))
+    actual = list(event_model.unpack_event_page(event_model.pack_event_page(*expected)))
     assert actual == expected
 
     # Round trip on docs that don't have a filled key
     unfilled_doc1 = event_doc1
-    unfilled_doc1.pop('filled')
+    unfilled_doc1.pop("filled")
     unfilled_doc2 = event_doc2
-    unfilled_doc2.pop('filled')
+    unfilled_doc2.pop("filled")
     unfilled_doc3 = event_doc3
-    unfilled_doc3.pop('filled')
+    unfilled_doc3.pop("filled")
     expected = [unfilled_doc1, unfilled_doc2, unfilled_doc3]
-    actual = list(event_model.unpack_event_page(
-        event_model.pack_event_page(*expected)))
+    actual = list(event_model.unpack_event_page(event_model.pack_event_page(*expected)))
     for doc in actual:
-        doc.pop('filled')
+        doc.pop("filled")
     assert actual == expected
 
     # Round trip one datum -> datum_page -> datum.
     expected = datum_doc1
-    actual, = event_model.unpack_datum_page(
-        event_model.pack_datum_page(expected))
+    (actual,) = event_model.unpack_datum_page(event_model.pack_datum_page(expected))
     assert actual == expected
 
     # Round trip two datum -> datum_page -> datum.
     expected = [datum_doc1, datum_doc2]
-    actual = list(event_model.unpack_datum_page(
-        event_model.pack_datum_page(*expected)))
+    actual = list(event_model.unpack_datum_page(event_model.pack_datum_page(*expected)))
     assert actual == expected
 
     # Round trip three datum -> datum_page -> datum.
     expected = [datum_doc1, datum_doc2, datum_doc3]
-    actual = list(event_model.unpack_datum_page(
-        event_model.pack_datum_page(*expected)))
+    actual = list(event_model.unpack_datum_page(event_model.pack_datum_page(*expected)))
     assert actual == expected
 
     # Check edge case where datum_kwargs are empty.
@@ -194,57 +231,65 @@ def test_round_trip_pagination():
 
     # Round trip one datum -> datum_page -> datum.
     expected = datum_doc1
-    actual, = event_model.unpack_datum_page(
-        event_model.pack_datum_page(expected))
+    (actual,) = event_model.unpack_datum_page(event_model.pack_datum_page(expected))
     assert actual == expected
 
     # Round trip two datum -> datum_page -> datum.
     expected = [datum_doc1, datum_doc2]
-    actual = list(event_model.unpack_datum_page(
-        event_model.pack_datum_page(*expected)))
+    actual = list(event_model.unpack_datum_page(event_model.pack_datum_page(*expected)))
     assert actual == expected
 
     # Round trip three datum -> datum_page -> datum.
     expected = [datum_doc1, datum_doc2, datum_doc3]
-    actual = list(event_model.unpack_datum_page(
-        event_model.pack_datum_page(*expected)))
+    actual = list(event_model.unpack_datum_page(event_model.pack_datum_page(*expected)))
     assert actual == expected
 
 
 def test_bulk_events_to_event_page(tmp_path):
     run_bundle = event_model.compose_run()
     desc_bundle = run_bundle.compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'},
-                   'image': {'shape': [512, 512], 'dtype': 'number',
-                             'source': '...', 'external': 'FILESTORE:'}},
-        name='primary')
+        data_keys={
+            "motor": {"shape": [], "dtype": "number", "source": "..."},
+            "image": {
+                "shape": [512, 512],
+                "dtype": "number",
+                "source": "...",
+                "external": "FILESTORE:",
+            },
+        },
+        name="primary",
+    )
     desc_bundle_baseline = run_bundle.compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'}},
-        name='baseline')
+        data_keys={"motor": {"shape": [], "dtype": "number", "source": "..."}},
+        name="baseline",
+    )
 
     path_root = str(tmp_path)
 
     res_bundle = run_bundle.compose_resource(
-        spec='TIFF', root=path_root, resource_path='stack.tiff',
-        resource_kwargs={})
-    datum_doc1 = res_bundle.compose_datum(datum_kwargs={'slice': 5})
-    datum_doc2 = res_bundle.compose_datum(datum_kwargs={'slice': 10})
+        spec="TIFF", root=path_root, resource_path="stack.tiff", resource_kwargs={}
+    )
+    datum_doc1 = res_bundle.compose_datum(datum_kwargs={"slice": 5})
+    datum_doc2 = res_bundle.compose_datum(datum_kwargs={"slice": 10})
     event1 = desc_bundle.compose_event(
-        data={'motor': 0, 'image': datum_doc1['datum_id']},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': False},
-        seq_num=1)
+        data={"motor": 0, "image": datum_doc1["datum_id"]},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": False},
+        seq_num=1,
+    )
     event2 = desc_bundle.compose_event(
-        data={'motor': 0, 'image': datum_doc2['datum_id']},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': False},
-        seq_num=2)
+        data={"motor": 0, "image": datum_doc2["datum_id"]},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": False},
+        seq_num=2,
+    )
     event3 = desc_bundle_baseline.compose_event(
-        data={'motor': 0},
-        timestamps={'motor': 0},
-        seq_num=1)
+        data={"motor": 0}, timestamps={"motor": 0}, seq_num=1
+    )
 
     primary_event_page = event_model.pack_event_page(event1, event2)
     baseline_event_page = event_model.pack_event_page(event3)
-    bulk_events = {'primary': [event1, event2], 'baseline': [event3]}
+    bulk_events = {"primary": [event1, event2], "baseline": [event3]}
     pages = event_model.bulk_events_to_event_pages(bulk_events)
     assert tuple(pages) == (primary_event_page, baseline_event_page)
 
@@ -252,28 +297,39 @@ def test_bulk_events_to_event_page(tmp_path):
 def test_sanitize_doc():
     run_bundle = event_model.compose_run()
     desc_bundle = run_bundle.compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'},
-                   'image': {'shape': [512, 512], 'dtype': 'number',
-                             'source': '...', 'external': 'FILESTORE:'}},
-        name='primary')
+        data_keys={
+            "motor": {"shape": [], "dtype": "number", "source": "..."},
+            "image": {
+                "shape": [512, 512],
+                "dtype": "number",
+                "source": "...",
+                "external": "FILESTORE:",
+            },
+        },
+        name="primary",
+    )
     desc_bundle_baseline = run_bundle.compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'}},
-        name='baseline')
+        data_keys={"motor": {"shape": [], "dtype": "number", "source": "..."}},
+        name="baseline",
+    )
     event1 = desc_bundle.compose_event(
-        data={'motor': 0, 'image': numpy.ones((512, 512))},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': True},
-        seq_num=1)
+        data={"motor": 0, "image": numpy.ones((512, 512))},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": True},
+        seq_num=1,
+    )
     event2 = desc_bundle.compose_event(
-        data={'motor': 0, 'image': numpy.ones((512, 512))},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': True},
-        seq_num=2)
+        data={"motor": 0, "image": numpy.ones((512, 512))},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": True},
+        seq_num=2,
+    )
     event3 = desc_bundle_baseline.compose_event(
-        data={'motor': 0},
-        timestamps={'motor': 0},
-        seq_num=1)
+        data={"motor": 0}, timestamps={"motor": 0}, seq_num=1
+    )
 
     event_page = event_model.pack_event_page(event1, event2)
-    bulk_events = {'primary': [event1, event2], 'baseline': [event3]}
+    bulk_events = {"primary": [event1, event2], "baseline": [event3]}
     json.dumps(event_model.sanitize_doc(event_page))
     json.dumps(event_model.sanitize_doc(bulk_events))
     json.dumps(event_model.sanitize_doc(event1))
@@ -282,16 +338,17 @@ def test_sanitize_doc():
 def test_bulk_datum_to_datum_page():
     run_bundle = event_model.compose_run()
     res_bundle = run_bundle.compose_resource(
-        spec='TIFF', root='/tmp', resource_path='stack.tiff',
-        resource_kwargs={})
-    datum1 = res_bundle.compose_datum(datum_kwargs={'slice': 5})
-    datum2 = res_bundle.compose_datum(datum_kwargs={'slice': 10})
+        spec="TIFF", root="/tmp", resource_path="stack.tiff", resource_kwargs={}
+    )
+    datum1 = res_bundle.compose_datum(datum_kwargs={"slice": 5})
+    datum2 = res_bundle.compose_datum(datum_kwargs={"slice": 10})
 
     actual = event_model.pack_datum_page(datum1, datum2)
-    bulk_datum = {'resource': res_bundle.resource_doc['uid'],
-                  'datum_kwarg_list': [datum1['datum_kwargs'],
-                                       datum2['datum_kwargs']],
-                  'datum_ids': [datum1['datum_id'], datum2['datum_id']]}
+    bulk_datum = {
+        "resource": res_bundle.resource_doc["uid"],
+        "datum_kwarg_list": [datum1["datum_kwargs"], datum2["datum_kwargs"]],
+        "datum_ids": [datum1["datum_id"], datum2["datum_id"]],
+    }
     expected = event_model.bulk_datum_to_datum_page(bulk_datum)
     assert actual == expected
 
@@ -299,41 +356,52 @@ def test_bulk_datum_to_datum_page():
 def test_document_router_smoke_test():
     dr = event_model.DocumentRouter()
     run_bundle = event_model.compose_run()
-    dr('start', run_bundle.start_doc)
+    dr("start", run_bundle.start_doc)
     desc_bundle = run_bundle.compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'},
-                   'image': {'shape': [512, 512], 'dtype': 'number',
-                             'source': '...', 'external': 'FILESTORE:'}},
-        name='primary')
-    dr('descriptor', desc_bundle.descriptor_doc)
+        data_keys={
+            "motor": {"shape": [], "dtype": "number", "source": "..."},
+            "image": {
+                "shape": [512, 512],
+                "dtype": "number",
+                "source": "...",
+                "external": "FILESTORE:",
+            },
+        },
+        name="primary",
+    )
+    dr("descriptor", desc_bundle.descriptor_doc)
     desc_bundle_baseline = run_bundle.compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'}},
-        name='baseline')
-    dr('descriptor', desc_bundle_baseline.descriptor_doc)
+        data_keys={"motor": {"shape": [], "dtype": "number", "source": "..."}},
+        name="baseline",
+    )
+    dr("descriptor", desc_bundle_baseline.descriptor_doc)
     res_bundle = run_bundle.compose_resource(
-        spec='TIFF', root='/tmp', resource_path='stack.tiff',
-        resource_kwargs={})
-    dr('resource', res_bundle.resource_doc)
-    datum_doc1 = res_bundle.compose_datum(datum_kwargs={'slice': 5})
-    datum_doc2 = res_bundle.compose_datum(datum_kwargs={'slice': 10})
-    dr('datum', datum_doc1)
-    dr('datum', datum_doc2)
+        spec="TIFF", root="/tmp", resource_path="stack.tiff", resource_kwargs={}
+    )
+    dr("resource", res_bundle.resource_doc)
+    datum_doc1 = res_bundle.compose_datum(datum_kwargs={"slice": 5})
+    datum_doc2 = res_bundle.compose_datum(datum_kwargs={"slice": 10})
+    dr("datum", datum_doc1)
+    dr("datum", datum_doc2)
     event1 = desc_bundle.compose_event(
-        data={'motor': 0, 'image': datum_doc1['datum_id']},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': False},
-        seq_num=1)
-    dr('event', event1)
+        data={"motor": 0, "image": datum_doc1["datum_id"]},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": False},
+        seq_num=1,
+    )
+    dr("event", event1)
     event2 = desc_bundle.compose_event(
-        data={'motor': 0, 'image': datum_doc2['datum_id']},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': False},
-        seq_num=2)
-    dr('event', event2)
+        data={"motor": 0, "image": datum_doc2["datum_id"]},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": False},
+        seq_num=2,
+    )
+    dr("event", event2)
     event3 = desc_bundle_baseline.compose_event(
-        data={'motor': 0},
-        timestamps={'motor': 0},
-        seq_num=1)
-    dr('event', event3)
-    dr('stop', run_bundle.compose_stop())
+        data={"motor": 0}, timestamps={"motor": 0}, seq_num=1
+    )
+    dr("event", event3)
+    dr("stop", run_bundle.compose_stop())
 
 
 def test_document_router_streams_smoke_test(tmp_path):
@@ -343,75 +411,95 @@ def test_document_router_streams_smoke_test(tmp_path):
     start = run_bundle.start_doc
     dr("start", start)
     stream_names = ["stream_1", "stream_2"]
-    stream_resource_doc, compose_stream_data = \
-        compose_stream_resource(spec="TIFF_STREAM", root=str(tmp_path), resource_path="test_streams",
-                                resource_kwargs={}, stream_names=stream_names)
+    stream_resource_doc, compose_stream_data = compose_stream_resource(
+        spec="TIFF_STREAM",
+        root=str(tmp_path),
+        resource_path="test_streams",
+        resource_kwargs={},
+        stream_names=stream_names,
+    )
     dr("stream_resource", stream_resource_doc)
-    datum_doc_0, datum_doc_1 = (compose_stream_datum(datum_kwargs={})
-                                for compose_stream_datum in compose_stream_data)
+    datum_doc_0, datum_doc_1 = (
+        compose_stream_datum(datum_kwargs={})
+        for compose_stream_datum in compose_stream_data
+    )
     dr("stream_datum", datum_doc_0)
     dr("stream_datum", datum_doc_1)
-    dr('stop', run_bundle.compose_stop())
+    dr("stop", run_bundle.compose_stop())
 
 
 def test_document_router_with_validation():
     dr = event_model.DocumentRouter()
     run_bundle = event_model.compose_run()
-    dr('start', run_bundle.start_doc, validate=True)
+    dr("start", run_bundle.start_doc, validate=True)
     desc_bundle = run_bundle.compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'},
-                   'image': {'shape': [512, 512], 'dtype': 'number',
-                             'source': '...', 'external': 'FILESTORE:'}},
-        name='primary')
-    dr('descriptor', desc_bundle.descriptor_doc, validate=True)
+        data_keys={
+            "motor": {"shape": [], "dtype": "number", "source": "..."},
+            "image": {
+                "shape": [512, 512],
+                "dtype": "number",
+                "source": "...",
+                "external": "FILESTORE:",
+            },
+        },
+        name="primary",
+    )
+    dr("descriptor", desc_bundle.descriptor_doc, validate=True)
     desc_bundle_baseline = run_bundle.compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'}},
-        name='baseline')
-    dr('descriptor', desc_bundle_baseline.descriptor_doc, validate=True)
+        data_keys={"motor": {"shape": [], "dtype": "number", "source": "..."}},
+        name="baseline",
+    )
+    dr("descriptor", desc_bundle_baseline.descriptor_doc, validate=True)
     res_bundle = run_bundle.compose_resource(
-        spec='TIFF', root='/tmp', resource_path='stack.tiff',
-        resource_kwargs={})
-    dr('resource', res_bundle.resource_doc, validate=True)
-    datum_doc1 = res_bundle.compose_datum(datum_kwargs={'slice': 5})
-    datum_doc2 = res_bundle.compose_datum(datum_kwargs={'slice': 10})
-    dr('datum', datum_doc1, validate=True)
-    dr('datum', datum_doc2, validate=True)
+        spec="TIFF", root="/tmp", resource_path="stack.tiff", resource_kwargs={}
+    )
+    dr("resource", res_bundle.resource_doc, validate=True)
+    datum_doc1 = res_bundle.compose_datum(datum_kwargs={"slice": 5})
+    datum_doc2 = res_bundle.compose_datum(datum_kwargs={"slice": 10})
+    dr("datum", datum_doc1, validate=True)
+    dr("datum", datum_doc2, validate=True)
     event1 = desc_bundle.compose_event(
-        data={'motor': 0, 'image': datum_doc1['datum_id']},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': False},
-        seq_num=1)
-    dr('event', event1, validate=True)
+        data={"motor": 0, "image": datum_doc1["datum_id"]},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": False},
+        seq_num=1,
+    )
+    dr("event", event1, validate=True)
     event2 = desc_bundle.compose_event(
-        data={'motor': 0, 'image': datum_doc2['datum_id']},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': False},
-        seq_num=2)
-    dr('event', event2, validate=True)
+        data={"motor": 0, "image": datum_doc2["datum_id"]},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": False},
+        seq_num=2,
+    )
+    dr("event", event2, validate=True)
     event3 = desc_bundle_baseline.compose_event(
-        data={'motor': 0},
-        timestamps={'motor': 0},
-        seq_num=1)
-    dr('event', event3, validate=True)
-    dr('stop', run_bundle.compose_stop(), validate=True)
+        data={"motor": 0}, timestamps={"motor": 0}, seq_num=1
+    )
+    dr("event", event3, validate=True)
+    dr("stop", run_bundle.compose_stop(), validate=True)
 
 
 def test_document_router_dispatch_event():
-
     event_calls = []  # used for counting calls
     event_page_calls = []  # used for counting calls
 
     # example documents
-    event1 = {'data': {'x': 1},
-              'timestamps': {'x': 0.},
-              'uid': 'placeholder X',
-              'descriptor': 'placeholder Y',
-              'time': 0.,
-              'seq_num': 1}
-    event2 = {'data': {'x': 2},
-              'timestamps': {'x': 1.},
-              'uid': 'placeholder X',
-              'descriptor': 'placeholder Y',
-              'time': 1.,
-              'seq_num': 2}
+    event1 = {
+        "data": {"x": 1},
+        "timestamps": {"x": 0.0},
+        "uid": "placeholder X",
+        "descriptor": "placeholder Y",
+        "time": 0.0,
+        "seq_num": 1,
+    }
+    event2 = {
+        "data": {"x": 2},
+        "timestamps": {"x": 1.0},
+        "uid": "placeholder X",
+        "descriptor": "placeholder Y",
+        "time": 1.0,
+        "seq_num": 2,
+    }
     event_page = event_model.pack_event_page(event1, event2)
 
     def check(ret, original=None):
@@ -421,8 +509,8 @@ def check(ret, original=None):
         if original is not None:
             # Verify that a copy is returned.
             assert doc is not original  # ret is such a poser, dude.
-            doc.pop('filled', None)
-            original.pop('filled', None)
+            doc.pop("filled", None)
+            original.pop("filled", None)
             assert doc == original
 
     class DefinesNeitherEventNorEventPage(event_model.DocumentRouter):
@@ -438,14 +526,14 @@ def event_page(self, doc):
 
     dr = DefinesNeitherEventNorEventPage()
     # Test that Event is routed to Event and EventPage.
-    check(dr('event', event1))
+    check(dr("event", event1))
     assert len(event_calls) == 1
     assert len(event_page_calls) == 1
     event_calls.clear()
     event_page_calls.clear()
     # Test that EventPage is routed to EventPage and Event *once* before
     # giving up.
-    check(dr('event_page', event_page))
+    check(dr("event_page", event_page))
     assert len(event_page_calls) == 1
     assert len(event_calls) == 1
     event_calls.clear()
@@ -455,7 +543,7 @@ class DefinesEventNotEventPage(event_model.DocumentRouter):
         def event(self, doc):
             # Just a dumb test that check something particular to these example
             # documents.
-            assert doc['data']['x'] == doc['seq_num']
+            assert doc["data"]["x"] == doc["seq_num"]
             event_calls.append(object())
             return dict(doc)
 
@@ -466,13 +554,13 @@ def event_page(self, doc):
 
     dr = DefinesEventNotEventPage()
     # Test that Event is routed to Event.
-    check(dr('event', event1), event1)
+    check(dr("event", event1), event1)
     assert len(event_calls) == 1
     assert len(event_page_calls) == 0
     event_calls.clear()
     event_page_calls.clear()
     # Test that EventPage is unpacked and routed to Event one at a time.
-    check(dr('event_page', event_page), event_page)
+    check(dr("event_page", event_page), event_page)
     assert len(event_page_calls) == 1
     assert len(event_calls) == 2
     event_calls.clear()
@@ -487,19 +575,19 @@ def event(self, doc):
         def event_page(self, doc):
             # Just a dumb test that check something particular to these example
             # documents.
-            assert doc['data']['x'][0] == 1
+            assert doc["data"]["x"][0] == 1
             event_page_calls.append(object())
             return dict(doc)
 
     dr = DefinesEventPageNotEvent()
     # Test that Event is packed and routed to EventPage.
-    check(dr('event', event1), event1)
+    check(dr("event", event1), event1)
     assert len(event_calls) == 1
     assert len(event_page_calls) == 1
     event_calls.clear()
     event_page_calls.clear()
     # Test that EventPage is routed to EventPage.
-    check(dr('event_page', event_page), event_page)
+    check(dr("event_page", event_page), event_page)
     assert len(event_page_calls) == 1
     assert len(event_calls) == 0
     event_calls.clear()
@@ -509,26 +597,26 @@ class DefinesEventPageAndEvent(event_model.DocumentRouter):
         def event(self, doc):
             # Just a dumb test that check something particular to these example
             # documents.
-            assert doc['data']['x'] == doc['seq_num']
+            assert doc["data"]["x"] == doc["seq_num"]
             event_calls.append(object())
             return dict(doc)
 
         def event_page(self, doc):
             # Just a dumb test that check something particular to these example
             # documents.
-            assert doc['data']['x'][0] == 1
+            assert doc["data"]["x"][0] == 1
             event_page_calls.append(object())
             return dict(doc)
 
     dr = DefinesEventPageAndEvent()
     # Test that Event is routed to Event.
-    check(dr('event', event1), event1)
+    check(dr("event", event1), event1)
     assert len(event_calls) == 1
     assert len(event_page_calls) == 0
     event_calls.clear()
     event_page_calls.clear()
     # Test that EventPage is routed to EventPage.
-    check(dr('event_page', event_page), event_page)
+    check(dr("event_page", event_page), event_page)
     assert len(event_page_calls) == 1
     assert len(event_calls) == 0
     event_calls.clear()
@@ -536,17 +624,20 @@ def event_page(self, doc):
 
 
 def test_document_router_dispatch_datum():
-
     datum_calls = []  # used for counting calls
     datum_page_calls = []  # used for counting calls
 
     # example documents
-    datum1 = {'datum_id': 'placeholder/1',
-              'resource': 'placeholder',
-              'datum_kwargs': {'index': 1}}
-    datum2 = {'datum_id': 'placholder/2',
-              'resource': 'placeholder',
-              'datum_kwargs': {'index': 2}}
+    datum1 = {
+        "datum_id": "placeholder/1",
+        "resource": "placeholder",
+        "datum_kwargs": {"index": 1},
+    }
+    datum2 = {
+        "datum_id": "placholder/2",
+        "resource": "placeholder",
+        "datum_kwargs": {"index": 2},
+    }
     datum_page = event_model.pack_datum_page(datum1, datum2)
 
     def check(ret, original=None):
@@ -571,14 +662,14 @@ def datum_page(self, doc):
 
     dr = DefinesNeitherDatumNorDatumPage()
     # Test that Datum is routed to Datum and DatumPage.
-    check(dr('datum', datum1))
+    check(dr("datum", datum1))
     assert len(datum_calls) == 1
     assert len(datum_page_calls) == 1
     datum_calls.clear()
     datum_page_calls.clear()
     # Test that DatumPage is routed to DatumPage and Datum *once* before giving
     # up.
-    check(dr('datum_page', datum_page))
+    check(dr("datum_page", datum_page))
     assert len(datum_page_calls) == 1
     assert len(datum_calls) == 1
     datum_calls.clear()
@@ -588,7 +679,7 @@ class DefinesDatumNotDatumPage(event_model.DocumentRouter):
         def datum(self, doc):
             # Just a dumb test that check something particular to these example
             # documents.
-            assert doc['datum_kwargs']['index'] == int(doc['datum_id'][-1])
+            assert doc["datum_kwargs"]["index"] == int(doc["datum_id"][-1])
             datum_calls.append(object())
             return dict(doc)
 
@@ -599,13 +690,13 @@ def datum_page(self, doc):
 
     dr = DefinesDatumNotDatumPage()
     # Test that Datum is routed to Datum.
-    check(dr('datum', datum1), datum1)
+    check(dr("datum", datum1), datum1)
     assert len(datum_calls) == 1
     assert len(datum_page_calls) == 0
     datum_calls.clear()
     datum_page_calls.clear()
     # Test that DatumPage is unpacked and routed to Datum one at a time.
-    check(dr('datum_page', datum_page), datum_page)
+    check(dr("datum_page", datum_page), datum_page)
     assert len(datum_page_calls) == 1
     assert len(datum_calls) == 2
     datum_calls.clear()
@@ -620,19 +711,19 @@ def datum(self, doc):
         def datum_page(self, doc):
             # Just a dumb test that check something particular to these example
             # documents.
-            assert doc['datum_kwargs']['index'][0] == int(doc['datum_id'][0][-1])
+            assert doc["datum_kwargs"]["index"][0] == int(doc["datum_id"][0][-1])
             datum_page_calls.append(object())
             return dict(doc)
 
     dr = DefinesDatumPageNotDatum()
     # Test that Datum is packed and routed to DatumPage.
-    check(dr('datum', datum1), datum1)
+    check(dr("datum", datum1), datum1)
     assert len(datum_calls) == 1
     assert len(datum_page_calls) == 1
     datum_calls.clear()
     datum_page_calls.clear()
     # Test that DatumPage is routed to DatumPage.
-    check(dr('datum_page', datum_page), datum_page)
+    check(dr("datum_page", datum_page), datum_page)
     assert len(datum_page_calls) == 1
     assert len(datum_calls) == 0
     datum_calls.clear()
@@ -643,26 +734,26 @@ class DefinesDatumPageAndDatum(event_model.DocumentRouter):
         def datum(self, doc):
             # Just a dumb test that check something particular to these example
             # documents.
-            assert doc['datum_kwargs']['index'] == int(doc['datum_id'][-1])
+            assert doc["datum_kwargs"]["index"] == int(doc["datum_id"][-1])
             datum_calls.append(object())
             return dict(doc)
 
         def datum_page(self, doc):
             # Just a dumb test that check something particular to these example
             # documents.
-            assert doc['datum_kwargs']['index'][0] == int(doc['datum_id'][0][-1])
+            assert doc["datum_kwargs"]["index"][0] == int(doc["datum_id"][0][-1])
             datum_page_calls.append(object())
             return dict(doc)
 
     dr = DefinesDatumPageAndDatum()
     # Test that Datum is routed to Datum.
-    check(dr('datum', datum1), datum1)
+    check(dr("datum", datum1), datum1)
     assert len(datum_calls) == 1
     assert len(datum_page_calls) == 0
     datum_calls.clear()
     datum_page_calls.clear()
     # Test that DatumPage is routed to DatumPage.
-    check(dr('datum_page', datum_page), datum_page)
+    check(dr("datum_page", datum_page), datum_page)
     assert len(datum_page_calls) == 1
     assert len(datum_calls) == 0
     datum_calls.clear()
@@ -675,42 +766,53 @@ def test_single_run_document_router():
         sr.get_start()
 
     run_bundle = event_model.compose_run()
-    sr('start', run_bundle.start_doc)
+    sr("start", run_bundle.start_doc)
     assert sr.get_start() == run_bundle.start_doc
 
     desc_bundle = run_bundle.compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'},
-                   'image': {'shape': [512, 512], 'dtype': 'number',
-                             'source': '...', 'external': 'FILESTORE:'}},
-        name='primary')
-    sr('descriptor', desc_bundle.descriptor_doc)
+        data_keys={
+            "motor": {"shape": [], "dtype": "number", "source": "..."},
+            "image": {
+                "shape": [512, 512],
+                "dtype": "number",
+                "source": "...",
+                "external": "FILESTORE:",
+            },
+        },
+        name="primary",
+    )
+    sr("descriptor", desc_bundle.descriptor_doc)
     desc_bundle_baseline = run_bundle.compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'}},
-        name='baseline')
-    sr('descriptor', desc_bundle_baseline.descriptor_doc)
+        data_keys={"motor": {"shape": [], "dtype": "number", "source": "..."}},
+        name="baseline",
+    )
+    sr("descriptor", desc_bundle_baseline.descriptor_doc)
     res_bundle = run_bundle.compose_resource(
-        spec='TIFF', root='/tmp', resource_path='stack.tiff',
-        resource_kwargs={})
-    sr('resource', res_bundle.resource_doc)
-    datum_doc1 = res_bundle.compose_datum(datum_kwargs={'slice': 5})
-    datum_doc2 = res_bundle.compose_datum(datum_kwargs={'slice': 10})
-    sr('datum', datum_doc1)
-    sr('datum', datum_doc2)
+        spec="TIFF", root="/tmp", resource_path="stack.tiff", resource_kwargs={}
+    )
+    sr("resource", res_bundle.resource_doc)
+    datum_doc1 = res_bundle.compose_datum(datum_kwargs={"slice": 5})
+    datum_doc2 = res_bundle.compose_datum(datum_kwargs={"slice": 10})
+    sr("datum", datum_doc1)
+    sr("datum", datum_doc2)
     event1 = desc_bundle.compose_event(
-        data={'motor': 0, 'image': datum_doc1['datum_id']},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': False},
-        seq_num=1)
-    sr('event', event1)
+        data={"motor": 0, "image": datum_doc1["datum_id"]},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": False},
+        seq_num=1,
+    )
+    sr("event", event1)
     event2 = desc_bundle.compose_event(
-        data={'motor': 0, 'image': datum_doc2['datum_id']},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': False},
-        seq_num=2)
-    sr('event', event2)
+        data={"motor": 0, "image": datum_doc2["datum_id"]},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": False},
+        seq_num=2,
+    )
+    sr("event", event2)
     event3 = desc_bundle_baseline.compose_event(
-        data={'motor': 0},
-        timestamps={'motor': 0},
-        seq_num=1)
-    sr('event', event3)
+        data={"motor": 0}, timestamps={"motor": 0}, seq_num=1
+    )
+    sr("event", event3)
 
     with pytest.raises(event_model.EventModelValueError):
         sr.get_descriptor(res_bundle.resource_doc)
@@ -719,19 +821,19 @@ def test_single_run_document_router():
         sr.get_descriptor(datum_doc1)
 
     assert sr.get_descriptor(event1) == desc_bundle.descriptor_doc
-    assert sr.get_stream_name(event1) == desc_bundle.descriptor_doc.get('name')
+    assert sr.get_stream_name(event1) == desc_bundle.descriptor_doc.get("name")
     assert sr.get_descriptor(event2) == desc_bundle.descriptor_doc
-    assert sr.get_stream_name(event2) == desc_bundle.descriptor_doc.get('name')
+    assert sr.get_stream_name(event2) == desc_bundle.descriptor_doc.get("name")
     assert sr.get_descriptor(event3) == desc_bundle_baseline.descriptor_doc
-    assert sr.get_stream_name(event3) == desc_bundle_baseline.descriptor_doc.get('name')
+    assert sr.get_stream_name(event3) == desc_bundle_baseline.descriptor_doc.get("name")
 
     desc_bundle_unused = run_bundle.compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'}},
-        name='unused')
+        data_keys={"motor": {"shape": [], "dtype": "number", "source": "..."}},
+        name="unused",
+    )
     event4 = desc_bundle_unused.compose_event(
-        data={'motor': 0},
-        timestamps={'motor': 0},
-        seq_num=1)
+        data={"motor": 0}, timestamps={"motor": 0}, seq_num=1
+    )
 
     with pytest.raises(event_model.EventModelValueError):
         sr.get_descriptor(event4)
@@ -739,42 +841,50 @@ def test_single_run_document_router():
     with pytest.raises(event_model.EventModelValueError):
         sr.get_stream_name(event4)
 
-    sr('stop', run_bundle.compose_stop())
+    sr("stop", run_bundle.compose_stop())
 
     # tests against a second run
     run_bundle = event_model.compose_run()
     with pytest.raises(event_model.EventModelValueError):
-        sr('start', run_bundle.start_doc)
+        sr("start", run_bundle.start_doc)
 
     desc_bundle = run_bundle.compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'},
-                   'image': {'shape': [512, 512], 'dtype': 'number',
-                             'source': '...', 'external': 'FILESTORE:'}},
-        name='primary')
+        data_keys={
+            "motor": {"shape": [], "dtype": "number", "source": "..."},
+            "image": {
+                "shape": [512, 512],
+                "dtype": "number",
+                "source": "...",
+                "external": "FILESTORE:",
+            },
+        },
+        name="primary",
+    )
     with pytest.raises(event_model.EventModelValueError):
-        sr('descriptor', desc_bundle.descriptor_doc)
+        sr("descriptor", desc_bundle.descriptor_doc)
 
 
 def test_rechunk_event_pages():
-
     def event_page_gen(page_size, num_pages):
         """
         Generator event_pages for testing.
         """
-        data_keys = ['x', 'y', 'z']
-        array_keys = ['seq_num', 'time', 'uid']
+        data_keys = ["x", "y", "z"]
+        array_keys = ["seq_num", "time", "uid"]
         for _ in range(num_pages):
-            yield {'descriptor': 'DESCRIPTOR',
-                   **{key: list(range(page_size)) for key in array_keys},
-                   'data': {key: list(range(page_size)) for key in data_keys},
-                   'timestamps': {key: list(range(page_size)) for key in data_keys},
-                   'filled': {key: list(range(page_size)) for key in data_keys}}
+            yield {
+                "descriptor": "DESCRIPTOR",
+                **{key: list(range(page_size)) for key in array_keys},
+                "data": {key: list(range(page_size)) for key in data_keys},
+                "timestamps": {key: list(range(page_size)) for key in data_keys},
+                "filled": {key: list(range(page_size)) for key in data_keys},
+            }
 
     # Get a list of event pages of size 13.
     event_pages = list(event_page_gen(13, 31))
     # Change the size of the event_pages to size 7.
     event_pages_7 = list(event_model.rechunk_event_pages(event_pages, 7))
-    assert [7] * 57 + [4] == [len(page['uid']) for page in event_pages_7]
+    assert [7] * 57 + [4] == [len(page["uid"]) for page in event_pages_7]
     # Change the size back to 13.
     event_pages_13 = event_model.rechunk_event_pages(event_pages_7, 13)
     # Check that it is equal to the original list of event_pages.
@@ -782,24 +892,24 @@ def event_page_gen(page_size, num_pages):
 
 
 def test_rechunk_datum_pages():
-
     def datum_page_gen(page_size, num_pages):
         """
         Generator datum_pages for testing.
         """
-        data_keys = ['x', 'y', 'z']
-        array_keys = ['datum_id']
+        data_keys = ["x", "y", "z"]
+        array_keys = ["datum_id"]
         for _ in range(num_pages):
-            yield {'resource': 'RESOURCE',
-                   **{key: list(range(page_size)) for key in array_keys},
-                   'datum_kwargs': {key: list(range(page_size))
-                                    for key in data_keys}}
+            yield {
+                "resource": "RESOURCE",
+                **{key: list(range(page_size)) for key in array_keys},
+                "datum_kwargs": {key: list(range(page_size)) for key in data_keys},
+            }
 
     # Get a list of datum pages of size 13.
     datum_pages = list(datum_page_gen(13, 31))
     # Change the size of the datum_pages to size 7.
     datum_pages_7 = list(event_model.rechunk_datum_pages(datum_pages, 7))
-    assert [7] * 57 + [4] == [len(page['datum_id']) for page in datum_pages_7]
+    assert [7] * 57 + [4] == [len(page["datum_id"]) for page in datum_pages_7]
     # Change the size back to 13.
     datum_pages_13 = event_model.rechunk_datum_pages(datum_pages_7, 13)
     # Check that it is equal to the original list of datum_pages.
@@ -813,17 +923,16 @@ def test_pack_empty_raises():
         event_model.pack_datum_page()
 
 
-@pytest.mark.parametrize('retry_intervals', [(1,), [1], (), [], None])
+@pytest.mark.parametrize("retry_intervals", [(1,), [1], (), [], None])
 def test_retry_intervals_input_normalization(retry_intervals):
-    filler = event_model.Filler({}, retry_intervals=retry_intervals,
-                                inplace=False)
+    filler = event_model.Filler({}, retry_intervals=retry_intervals, inplace=False)
     assert isinstance(filler.retry_intervals, list)
 
 
 def test_attempt_with_retires():
     mutable = []
     expected_args = (1, 2)
-    expected_kwargs = {'c': 3, 'd': 4}
+    expected_kwargs = {"c": 3, "d": 4}
     expected_result = 10
 
     class LocalException1(Exception):
@@ -849,7 +958,8 @@ def func(*args, **kwargs):
         kwargs=expected_kwargs,
         error_to_catch=LocalException1,
         error_to_raise=LocalException2,
-        intervals=[0, 0.01, 0.01])
+        intervals=[0, 0.01, 0.01],
+    )
     assert result == expected_result
 
     mutable.clear()
@@ -862,18 +972,20 @@ def func(*args, **kwargs):
             kwargs=expected_kwargs,
             error_to_catch=LocalException1,
             error_to_raise=LocalException2,
-            intervals=[0, 0.01])
+            intervals=[0, 0.01],
+        )
 
 
 def test_round_trip_event_page_with_empty_data():
     event_page = {
-        'time': [1, 2, 3],
-        'seq_num': [1, 2, 3],
-        'uid': ['a', 'b', 'c'],
-        'descriptor': 'd',
-        'data': {},
-        'timestamps': {},
-        'filled': {}}
+        "time": [1, 2, 3],
+        "seq_num": [1, 2, 3],
+        "uid": ["a", "b", "c"],
+        "descriptor": "d",
+        "data": {},
+        "timestamps": {},
+        "filled": {},
+    }
     events = list(event_model.unpack_event_page(event_page))
     assert len(events) == 3
 
@@ -882,10 +994,7 @@ def test_round_trip_event_page_with_empty_data():
 
 
 def test_round_trip_datum_page_with_empty_data():
-    datum_page = {
-        'datum_id': ['a', 'b', 'c'],
-        'resource': 'd',
-        'datum_kwargs': {}}
+    datum_page = {"datum_id": ["a", "b", "c"], "resource": "d", "datum_kwargs": {}}
     datums = list(event_model.unpack_datum_page(datum_page))
     assert len(datums) == 3
 
@@ -895,23 +1004,23 @@ def test_round_trip_datum_page_with_empty_data():
 
 def test_register_coercion():
     # Re-registration should be fine.
-    assert 'as_is' in event_model._coercion_registry  # implementation detail
-    event_model.register_coercion('as_is', event_model.as_is)
+    assert "as_is" in event_model._coercion_registry  # implementation detail
+    event_model.register_coercion("as_is", event_model.as_is)
 
     # but registering something different to the same name should raise.
     with pytest.raises(event_model.EventModelValueError):
-        event_model.register_coercion('as_is', object)
+        event_model.register_coercion("as_is", object)
 
 
 def test_register_coercion_misspelled():
     "The function register_coercion was originally released as register_coersion."
     # Re-registration should be fine.
-    assert 'as_is' in event_model._coercion_registry  # implementation detail
-    event_model.register_coersion('as_is', event_model.as_is)
+    assert "as_is" in event_model._coercion_registry  # implementation detail
+    event_model.register_coersion("as_is", event_model.as_is)
 
     # but registering something different to the same name should raise.
     with pytest.raises(event_model.EventModelValueError):
-        event_model.register_coersion('as_is', object)
+        event_model.register_coersion("as_is", object)
 
 
 def test_pickle_filler():
@@ -927,13 +1036,12 @@ def test_array_like():
     dask_array = pytest.importorskip("dask.array")
     bundle = event_model.compose_run()
     desc_bundle = bundle.compose_descriptor(
-        data_keys={"a": {"shape": (3,), "dtype": "array", "source": ""}},
-        name="primary"
+        data_keys={"a": {"shape": (3,), "dtype": "array", "source": ""}}, name="primary"
     )
     desc_bundle.compose_event_page(
         data={"a": dask_array.ones((5, 3))},
         timestamps={"a": [1, 2, 3]},
-        seq_num=[1, 2, 3]
+        seq_num=[1, 2, 3],
     )
 
 
diff --git a/event_model/tests/test_emit.py b/event_model/tests/test_emit.py
index 21efacf8b..aa6afb5d1 100644
--- a/event_model/tests/test_emit.py
+++ b/event_model/tests/test_emit.py
@@ -1,8 +1,9 @@
 import gc
 
-import event_model
 import pytest
 
+import event_model
+
 
 def test_emit():
     collector = []
@@ -11,8 +12,8 @@ def cb(name, doc):
         collector.append((name, doc))
 
     router = event_model.DocumentRouter(emit=cb)
-    name = 'start'
-    doc = {'uid': 'asdf', 'time': 0}
+    name = "start"
+    doc = {"uid": "asdf", "time": 0}
     router.emit(name, doc)
     assert len(collector) == 1
     assert collector == [(name, doc)]
@@ -34,8 +35,8 @@ def cb(self, name, doc):
     thing = Thing()
 
     router = event_model.DocumentRouter(emit=thing.cb)
-    name = 'start'
-    doc = {'uid': 'asdf', 'time': 0}
+    name = "start"
+    doc = {"uid": "asdf", "time": 0}
     router.emit(name, doc)
     assert len(collector) == 1
     assert collector == [(name, doc)]
diff --git a/event_model/tests/test_filler.py b/event_model/tests/test_filler.py
index a62e97dcc..369fdabd2 100644
--- a/event_model/tests/test_filler.py
+++ b/event_model/tests/test_filler.py
@@ -1,33 +1,46 @@
 import copy
 import pathlib
-import event_model
+
 import numpy
 import pytest
 
+import event_model
 
 path_root = pathlib.Path("/placeholder/path")
 run_bundle = event_model.compose_run()
 desc_bundle = run_bundle.compose_descriptor(
-    data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'},
-               'image': {'shape': [512, 512], 'dtype': 'number',
-                         'source': '...', 'external': 'FILESTORE:'}},
-    name='primary')
+    data_keys={
+        "motor": {"shape": [], "dtype": "number", "source": "..."},
+        "image": {
+            "shape": [512, 512],
+            "dtype": "number",
+            "source": "...",
+            "external": "FILESTORE:",
+        },
+    },
+    name="primary",
+)
 desc_bundle_baseline = run_bundle.compose_descriptor(
-    data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'}},
-    name='baseline')
+    data_keys={"motor": {"shape": [], "dtype": "number", "source": "..."}},
+    name="baseline",
+)
 res_bundle = run_bundle.compose_resource(
-    spec='DUMMY', root=str(path_root), resource_path='stack.tiff',
-    resource_kwargs={'a': 1, 'b': 2})
-datum_doc = res_bundle.compose_datum(datum_kwargs={'c': 3, 'd': 4})
+    spec="DUMMY",
+    root=str(path_root),
+    resource_path="stack.tiff",
+    resource_kwargs={"a": 1, "b": 2},
+)
+datum_doc = res_bundle.compose_datum(datum_kwargs={"c": 3, "d": 4})
 raw_event = desc_bundle.compose_event(
-    data={'motor': 0, 'image': datum_doc['datum_id']},
-    timestamps={'motor': 0, 'image': 0}, filled={'image': False},
-    seq_num=1)
+    data={"motor": 0, "image": datum_doc["datum_id"]},
+    timestamps={"motor": 0, "image": 0},
+    filled={"image": False},
+    seq_num=1,
+)
 stop_doc = run_bundle.compose_stop()
 
 
 class DummyHandler:
-
     def __init__(self, resource_path, a, b):
         assert a == 1
         assert b == 2
@@ -39,22 +52,22 @@ def __call__(self, c, d):
         return numpy.ones((5, 5))
 
 
-reg = {'DUMMY': DummyHandler}
+reg = {"DUMMY": DummyHandler}
 
 
 @pytest.fixture
 def filler():
     filler = event_model.Filler(reg, inplace=True)
-    filler('start', run_bundle.start_doc)
-    filler('descriptor', desc_bundle.descriptor_doc)
-    filler('descriptor', desc_bundle_baseline.descriptor_doc)
-    filler('resource', res_bundle.resource_doc)
-    filler('datum', datum_doc)
+    filler("start", run_bundle.start_doc)
+    filler("descriptor", desc_bundle.descriptor_doc)
+    filler("descriptor", desc_bundle_baseline.descriptor_doc)
+    filler("resource", res_bundle.resource_doc)
+    filler("datum", datum_doc)
     event = copy.deepcopy(raw_event)
-    assert isinstance(event['data']['image'], str)
-    filler('event', event)
-    assert event['data']['image'].shape == (5, 5)
-    filler('stop', stop_doc)
+    assert isinstance(event["data"]["image"], str)
+    filler("event", event)
+    assert event["data"]["image"].shape == (5, 5)
+    filler("stop", stop_doc)
     assert not filler.closed
     return filler
 
@@ -62,17 +75,17 @@ def filler():
 def test_no_filler():
     "Test that NoFiller does not fill data."
     filler = event_model.NoFiller(reg)
-    filler('start', run_bundle.start_doc)
-    filler('descriptor', desc_bundle.descriptor_doc)
-    filler('descriptor', desc_bundle_baseline.descriptor_doc)
-    filler('resource', res_bundle.resource_doc)
-    filler('datum', datum_doc)
+    filler("start", run_bundle.start_doc)
+    filler("descriptor", desc_bundle.descriptor_doc)
+    filler("descriptor", desc_bundle_baseline.descriptor_doc)
+    filler("resource", res_bundle.resource_doc)
+    filler("datum", datum_doc)
     event = copy.deepcopy(raw_event)
-    assert isinstance(event['data']['image'], str)
-    filler('event', event)
+    assert isinstance(event["data"]["image"], str)
+    filler("event", event)
     # Check that it *hasn't* been filled.
-    assert isinstance(event['data']['image'], str)
-    filler('stop', stop_doc)
+    assert isinstance(event["data"]["image"], str)
+    filler("stop", stop_doc)
 
 
 def test_get_handler(filler):
@@ -89,56 +102,56 @@ def test_close(filler):
     with pytest.raises(event_model.EventModelRuntimeError):
         filler.get_handler(res_bundle.resource_doc)
     with pytest.raises(event_model.EventModelRuntimeError):
-        filler('stop', stop_doc)
+        filler("stop", stop_doc)
 
 
 def test_context_manager():
     "Construct Filler as a context manager."
     with event_model.Filler(reg, inplace=True) as filler:
-        filler('start', run_bundle.start_doc)
-        filler('descriptor', desc_bundle.descriptor_doc)
-        filler('descriptor', desc_bundle_baseline.descriptor_doc)
-        filler('resource', res_bundle.resource_doc)
-        filler('datum', datum_doc)
+        filler("start", run_bundle.start_doc)
+        filler("descriptor", desc_bundle.descriptor_doc)
+        filler("descriptor", desc_bundle_baseline.descriptor_doc)
+        filler("resource", res_bundle.resource_doc)
+        filler("datum", datum_doc)
         event = copy.deepcopy(raw_event)
-        name, doc = filler('event', event)
-        assert name == 'event'
+        name, doc = filler("event", event)
+        assert name == "event"
         assert doc is event
-        filler('stop', stop_doc)
+        filler("stop", stop_doc)
         assert not filler.closed
-    assert event['data']['image'].shape == (5, 5)
+    assert event["data"]["image"].shape == (5, 5)
     assert filler.closed
 
 
 def test_context_manager_with_event_page():
     with event_model.Filler(reg, inplace=True) as filler:
-        filler('start', run_bundle.start_doc)
-        filler('descriptor', desc_bundle.descriptor_doc)
-        filler('descriptor', desc_bundle_baseline.descriptor_doc)
-        filler('resource', res_bundle.resource_doc)
-        filler('datum', datum_doc)
+        filler("start", run_bundle.start_doc)
+        filler("descriptor", desc_bundle.descriptor_doc)
+        filler("descriptor", desc_bundle_baseline.descriptor_doc)
+        filler("resource", res_bundle.resource_doc)
+        filler("datum", datum_doc)
         event_page = event_model.pack_event_page(copy.deepcopy(raw_event))
-        name, doc = filler('event_page', event_page)
-        assert name == 'event_page'
+        name, doc = filler("event_page", event_page)
+        assert name == "event_page"
         assert doc is event_page
-        filler('stop', stop_doc)
+        filler("stop", stop_doc)
         assert not filler.closed
-    assert event_page['data']['image'][0].shape == (5, 5)
+    assert event_page["data"]["image"][0].shape == (5, 5)
     assert filler.closed
 
 
 def test_undefined_handler_spec():
     "Check failure path when an unknown spec is found in a resource."
     with event_model.Filler({}, inplace=True) as filler:
-        filler('start', run_bundle.start_doc)
-        filler('descriptor', desc_bundle.descriptor_doc)
-        filler('descriptor', desc_bundle_baseline.descriptor_doc)
-        filler('resource', res_bundle.resource_doc)
-        filler('datum', datum_doc)
+        filler("start", run_bundle.start_doc)
+        filler("descriptor", desc_bundle.descriptor_doc)
+        filler("descriptor", desc_bundle_baseline.descriptor_doc)
+        filler("resource", res_bundle.resource_doc)
+        filler("datum", datum_doc)
         event = copy.deepcopy(raw_event)
-        assert isinstance(event['data']['image'], str)
+        assert isinstance(event["data"]["image"], str)
         with pytest.raises(event_model.UndefinedAssetSpecification):
-            filler('event', event)
+            filler("event", event)
 
 
 def test_include_and_exclude():
@@ -146,47 +159,47 @@ def test_include_and_exclude():
         event_model.Filler({}, include=[], exclude=[], inplace=True)
 
     with pytest.warns(DeprecationWarning):
-        with event_model.Filler(reg, exclude=['image'], inplace=True) as filler:
-            filler('start', run_bundle.start_doc)
-            filler('descriptor', desc_bundle.descriptor_doc)
-            filler('descriptor', desc_bundle_baseline.descriptor_doc)
-            filler('resource', res_bundle.resource_doc)
-            filler('datum', datum_doc)
+        with event_model.Filler(reg, exclude=["image"], inplace=True) as filler:
+            filler("start", run_bundle.start_doc)
+            filler("descriptor", desc_bundle.descriptor_doc)
+            filler("descriptor", desc_bundle_baseline.descriptor_doc)
+            filler("resource", res_bundle.resource_doc)
+            filler("datum", datum_doc)
             event = copy.deepcopy(raw_event)
-            assert isinstance(event['data']['image'], str)
-            filler('event', event)
-            filler('stop', stop_doc)
+            assert isinstance(event["data"]["image"], str)
+            filler("event", event)
+            filler("stop", stop_doc)
 
     with pytest.warns(DeprecationWarning):
-        with event_model.Filler(reg, include=['image'], inplace=True) as filler:
-            filler('start', run_bundle.start_doc)
-            filler('descriptor', desc_bundle.descriptor_doc)
-            filler('descriptor', desc_bundle_baseline.descriptor_doc)
-            filler('resource', res_bundle.resource_doc)
-            filler('datum', datum_doc)
+        with event_model.Filler(reg, include=["image"], inplace=True) as filler:
+            filler("start", run_bundle.start_doc)
+            filler("descriptor", desc_bundle.descriptor_doc)
+            filler("descriptor", desc_bundle_baseline.descriptor_doc)
+            filler("resource", res_bundle.resource_doc)
+            filler("datum", datum_doc)
             event = copy.deepcopy(raw_event)
-            filler('event', event)
-            filler('stop', stop_doc)
+            filler("event", event)
+            filler("stop", stop_doc)
             assert not filler.closed
-    assert event['data']['image'].shape == (5, 5)
+    assert event["data"]["image"].shape == (5, 5)
 
     with pytest.warns(DeprecationWarning):
-        with event_model.Filler(reg, include=['image', 'EXTRA THING'],
-                                inplace=True) as filler:
-            filler('start', run_bundle.start_doc)
-            filler('descriptor', desc_bundle.descriptor_doc)
-            filler('descriptor', desc_bundle_baseline.descriptor_doc)
-            filler('resource', res_bundle.resource_doc)
-            filler('datum', datum_doc)
+        with event_model.Filler(
+            reg, include=["image", "EXTRA THING"], inplace=True
+        ) as filler:
+            filler("start", run_bundle.start_doc)
+            filler("descriptor", desc_bundle.descriptor_doc)
+            filler("descriptor", desc_bundle_baseline.descriptor_doc)
+            filler("resource", res_bundle.resource_doc)
+            filler("datum", datum_doc)
             event = copy.deepcopy(raw_event)
-            filler('event', event)
-            filler('stop', stop_doc)
+            filler("event", event)
+            filler("stop", stop_doc)
             assert not filler.closed
-    assert event['data']['image'].shape == (5, 5)
+    assert event["data"]["image"].shape == (5, 5)
 
 
 def test_root_map():
-
     new_path = pathlib.Path("/another_placeholder", "moved")
 
     class DummyHandlerRootMapTest:
@@ -200,20 +213,21 @@ def __call__(self, c, d):
             assert d == 4
             return numpy.ones((5, 5))
 
-    with event_model.Filler({'DUMMY': DummyHandlerRootMapTest},
-                            root_map={str(path_root): str(new_path)},
-                            inplace=True) as filler:
-
-        filler('start', run_bundle.start_doc)
-        filler('descriptor', desc_bundle.descriptor_doc)
-        filler('descriptor', desc_bundle_baseline.descriptor_doc)
-        filler('resource', res_bundle.resource_doc)
-        filler('datum', datum_doc)
+    with event_model.Filler(
+        {"DUMMY": DummyHandlerRootMapTest},
+        root_map={str(path_root): str(new_path)},
+        inplace=True,
+    ) as filler:
+        filler("start", run_bundle.start_doc)
+        filler("descriptor", desc_bundle.descriptor_doc)
+        filler("descriptor", desc_bundle_baseline.descriptor_doc)
+        filler("resource", res_bundle.resource_doc)
+        filler("datum", datum_doc)
         event = copy.deepcopy(raw_event)
-        filler('event', event)
-        filler('stop', stop_doc)
+        filler("event", event)
+        filler("stop", stop_doc)
         assert not filler.closed
-    assert event['data']['image'].shape == (5, 5)
+    assert event["data"]["image"].shape == (5, 5)
 
 
 def test_verify_filled(filler):
@@ -221,21 +235,21 @@ def test_verify_filled(filler):
     with pytest.raises(event_model.UnfilledData):
         event_model.verify_filled(event_model.pack_event_page(raw_event))
     event = copy.deepcopy(raw_event)
-    name, doc = filler('event', event)
+    name, doc = filler("event", event)
     event_model.verify_filled(event_model.pack_event_page(event))
 
 
 def test_inplace():
     "Test the behavior of the 'inplace' parameter."
     with event_model.Filler(reg, inplace=True) as filler:
-        filler('start', run_bundle.start_doc)
-        filler('descriptor', desc_bundle.descriptor_doc)
-        filler('descriptor', desc_bundle_baseline.descriptor_doc)
-        filler('resource', res_bundle.resource_doc)
-        filler('datum', datum_doc)
+        filler("start", run_bundle.start_doc)
+        filler("descriptor", desc_bundle.descriptor_doc)
+        filler("descriptor", desc_bundle_baseline.descriptor_doc)
+        filler("resource", res_bundle.resource_doc)
+        filler("datum", datum_doc)
         # Test event()
         event = copy.deepcopy(raw_event)
-        name, filled_event = filler('event', event)
+        name, filled_event = filler("event", event)
         assert filled_event is event
         event = copy.deepcopy(raw_event)
         # Test fill_event()
@@ -243,7 +257,7 @@ def test_inplace():
         assert filled_event is event
         # Test event_page()
         event_page = event_model.pack_event_page(copy.deepcopy(raw_event))
-        _, filled_event_page = filler('event_page', event_page)
+        _, filled_event_page = filler("event_page", event_page)
         assert filled_event_page is event_page
         # Test fill_event_page()
         event_page = event_model.pack_event_page(copy.deepcopy(raw_event))
@@ -261,15 +275,15 @@ def test_inplace():
         assert filled_event_page is not event_page
 
     with event_model.Filler(reg, inplace=False) as filler:
-        filler('start', run_bundle.start_doc)
-        filler('descriptor', desc_bundle.descriptor_doc)
-        filler('descriptor', desc_bundle_baseline.descriptor_doc)
-        filler('resource', res_bundle.resource_doc)
-        filler('datum', datum_doc)
+        filler("start", run_bundle.start_doc)
+        filler("descriptor", desc_bundle.descriptor_doc)
+        filler("descriptor", desc_bundle_baseline.descriptor_doc)
+        filler("resource", res_bundle.resource_doc)
+        filler("datum", datum_doc)
         event = copy.deepcopy(raw_event)
-        name, filled_event = filler('event', event)
+        name, filled_event = filler("event", event)
         assert filled_event is not event
-        assert isinstance(event['data']['image'], str)
+        assert isinstance(event["data"]["image"], str)
 
         event = copy.deepcopy(raw_event)
         # Test fill_event()
@@ -277,7 +291,7 @@ def test_inplace():
         assert filled_event is not event
         # Test event_page()
         event_page = event_model.pack_event_page(copy.deepcopy(raw_event))
-        _, filled_event_page = filler('event_page', event_page)
+        _, filled_event_page = filler("event_page", event_page)
         assert filled_event_page is not event_page
         # Test fill_event_page()
         event_page = event_model.pack_event_page(copy.deepcopy(raw_event))
@@ -304,6 +318,7 @@ def test_handler_registry_access():
 
     class OtherDummyHandler:
         "Same as DummyHandler, but a different object to test mutating reg"
+
         def __init__(self, resource_path, a, b):
             assert a == 1
             assert b == 2
@@ -317,69 +332,70 @@ def __call__(self, c, d):
     with event_model.Filler(reg, inplace=False) as filler:
         with pytest.raises(event_model.EventModelTypeError):
             # Updating an existing key fails.
-            filler.handler_registry['DUMMY'] = OtherDummyHandler
+            filler.handler_registry["DUMMY"] = OtherDummyHandler
         with pytest.raises(event_model.EventModelTypeError):
             # Setting a new key fails.
-            filler.handler_registry['SOMETHING_ELSE'] = OtherDummyHandler
+            filler.handler_registry["SOMETHING_ELSE"] = OtherDummyHandler
         with pytest.raises(event_model.EventModelTypeError):
             # Deleting a item fails.
-            del filler.handler_registry['DUMMY']
-        filler('start', run_bundle.start_doc)
-        filler('descriptor', desc_bundle.descriptor_doc)
-        filler('descriptor', desc_bundle_baseline.descriptor_doc)
-        filler('resource', res_bundle.resource_doc)
-        filler('datum', datum_doc)
+            del filler.handler_registry["DUMMY"]
+        filler("start", run_bundle.start_doc)
+        filler("descriptor", desc_bundle.descriptor_doc)
+        filler("descriptor", desc_bundle_baseline.descriptor_doc)
+        filler("resource", res_bundle.resource_doc)
+        filler("datum", datum_doc)
         event = copy.deepcopy(raw_event)
-        name, filled_event = filler('event', event)
+        name, filled_event = filler("event", event)
         assert filled_event is not event
-        assert isinstance(event['data']['image'], str)
+        assert isinstance(event["data"]["image"], str)
         # Now there should be a handler instance in the cache.
         assert filler._handler_cache  # implementation detail
         with pytest.raises(event_model.DuplicateHandler):
-            filler.register_handler('DUMMY', OtherDummyHandler)
-        filler.register_handler('DUMMY', OtherDummyHandler, overwrite=True)
-        assert filler.handler_registry['DUMMY'] is OtherDummyHandler
+            filler.register_handler("DUMMY", OtherDummyHandler)
+        filler.register_handler("DUMMY", OtherDummyHandler, overwrite=True)
+        assert filler.handler_registry["DUMMY"] is OtherDummyHandler
         # Replacing the handler for a given spec should clear the cache.
         assert not filler._handler_cache  # implementation detail
         # Filling should work the same....
-        filler('start', run_bundle.start_doc)
-        filler('descriptor', desc_bundle.descriptor_doc)
-        filler('descriptor', desc_bundle_baseline.descriptor_doc)
-        filler('resource', res_bundle.resource_doc)
-        filler('datum', datum_doc)
+        filler("start", run_bundle.start_doc)
+        filler("descriptor", desc_bundle.descriptor_doc)
+        filler("descriptor", desc_bundle_baseline.descriptor_doc)
+        filler("resource", res_bundle.resource_doc)
+        filler("datum", datum_doc)
         event = copy.deepcopy(raw_event)
-        name, filled_event = filler('event', event)
+        name, filled_event = filler("event", event)
         assert filled_event is not event
-        assert isinstance(event['data']['image'], str)
-        handler = filler.deregister_handler('DUMMY')
+        assert isinstance(event["data"]["image"], str)
+        handler = filler.deregister_handler("DUMMY")
         assert handler is OtherDummyHandler
         assert not filler.handler_registry
         assert not filler._handler_cache  # implementation detail
 
 
 def test_mismatched_data_keys():
-    "Test that we raise specifically when data keys do not match between event and descriptor."
+    "Test that we raise specifically when data keys do not match"
+    "between event and descriptor."
     with pytest.raises(event_model.MismatchedDataKeys):
         with event_model.NoFiller(reg) as filler:
-            filler('start', run_bundle.start_doc)
-            filler('descriptor', desc_bundle.descriptor_doc)
-            filler('descriptor', desc_bundle_baseline.descriptor_doc)
-            filler('resource', res_bundle.resource_doc)
-            filler('datum', datum_doc)
+            filler("start", run_bundle.start_doc)
+            filler("descriptor", desc_bundle.descriptor_doc)
+            filler("descriptor", desc_bundle_baseline.descriptor_doc)
+            filler("resource", res_bundle.resource_doc)
+            filler("datum", datum_doc)
             event = copy.deepcopy(raw_event)
-            del event['data']['image']
-            filler('event', event)
+            del event["data"]["image"]
+            filler("event", event)
 
     with pytest.raises(event_model.MismatchedDataKeys):
         with event_model.Filler(reg, inplace=False) as filler:
-            filler('start', run_bundle.start_doc)
-            filler('descriptor', desc_bundle.descriptor_doc)
-            filler('descriptor', desc_bundle_baseline.descriptor_doc)
-            filler('resource', res_bundle.resource_doc)
-            filler('datum', datum_doc)
+            filler("start", run_bundle.start_doc)
+            filler("descriptor", desc_bundle.descriptor_doc)
+            filler("descriptor", desc_bundle_baseline.descriptor_doc)
+            filler("resource", res_bundle.resource_doc)
+            filler("datum", datum_doc)
             event = copy.deepcopy(raw_event)
-            del event['data']['image']
-            filler('event', event)
+            del event["data"]["image"]
+            filler("event", event)
 
 
 def test_clear_caches(filler):
diff --git a/event_model/tests/test_projections.py b/event_model/tests/test_projections.py
index 490fbee7b..645879068 100644
--- a/event_model/tests/test_projections.py
+++ b/event_model/tests/test_projections.py
@@ -1,10 +1,12 @@
 from distutils.version import LooseVersion
-import event_model
+
+import jsonschema
 import pytest
 from jsonschema.exceptions import ValidationError
-import jsonschema
 
-skip_json_validations = LooseVersion(jsonschema.__version__) < LooseVersion('3.0.0')
+import event_model
+
+skip_json_validations = LooseVersion(jsonschema.__version__) < LooseVersion("3.0.0")
 
 
 @pytest.fixture
@@ -14,7 +16,9 @@ def start():
 
 
 def test_projection_in_start_doc():
-    run_bundle = event_model.compose_run(uid="42", metadata={"projections": valid_projections})
+    run_bundle = event_model.compose_run(
+        uid="42", metadata={"projections": valid_projections}
+    )
     start_doc = run_bundle.start_doc
     assert start_doc["projections"] == valid_projections
 
@@ -24,145 +28,172 @@ def test_projection_schema(start):
     event_model.schema_validators[event_model.DocumentNames.start].validate(start)
 
 
-@pytest.mark.skipif(skip_json_validations,
-                    reason="projection schema uses draft7 conditions")
+@pytest.mark.skipif(
+    skip_json_validations, reason="projection schema uses draft7 conditions"
+)
 def test_bad_calc_field(start):
     bad_calc_projections = [
-                # calc requires the calc fields
-                {
-                    "name": "test",
-                    "version": "42.0.0",
-                    "configuration": {},
-                    "projection": {
-                                "linked_field": {
-                                    "type": "calculated",
-                                    "location": "event",
-                                    "stream": "primary",
-                                    "field": "ccd",
-                                    }, }, }, ]
+        # calc requires the calc fields
+        {
+            "name": "test",
+            "version": "42.0.0",
+            "configuration": {},
+            "projection": {
+                "linked_field": {
+                    "type": "calculated",
+                    "location": "event",
+                    "stream": "primary",
+                    "field": "ccd",
+                },
+            },
+        },
+    ]
 
     start["projections"] = bad_calc_projections
-    with pytest.raises(ValidationError, ):
+    with pytest.raises(
+        ValidationError,
+    ):
         event_model.schema_validators[event_model.DocumentNames.start].validate(start)
 
 
-@pytest.mark.skipif(skip_json_validations,
-                    reason="projection schema uses draft7 conditions")
+@pytest.mark.skipif(
+    skip_json_validations, reason="projection schema uses draft7 conditions"
+)
 def test_bad_configuration_field(start):
     bad_configuration_projections = [
-                {
-                    "name": "test",
-                    "version": "42.0.0",
-                    "configuration": {},
-                    "projection": {
-                              "bad_config_field": {
-                                "type": "calculated",
-                                "location": "event",
-                                "config_index": 0,
-                                "config_device": "camera",
-                                "stream": "primary",
-                                # "field": "setting"
-                                }, }, }, ]
+        {
+            "name": "test",
+            "version": "42.0.0",
+            "configuration": {},
+            "projection": {
+                "bad_config_field": {
+                    "type": "calculated",
+                    "location": "event",
+                    "config_index": 0,
+                    "config_device": "camera",
+                    "stream": "primary",
+                    # "field": "setting"
+                },
+            },
+        },
+    ]
 
     start["projections"] = bad_configuration_projections
-    with pytest.raises(ValidationError, ):
+    with pytest.raises(
+        ValidationError,
+    ):
         event_model.schema_validators[event_model.DocumentNames.start].validate(start)
 
 
-@pytest.mark.skipif(skip_json_validations,
-                    reason="projection schema uses draft7 conditions")
+@pytest.mark.skipif(
+    skip_json_validations, reason="projection schema uses draft7 conditions"
+)
 def test_bad_event_field(start):
     bad_event_projections = [
-                {
-                    "name": "test",
-                    "version": "42.0.0",
-                    "configuration": {},
-                    "projection": {
-                                 "bad_event_field": {
-                                    "type": "linked",
-                                    "location": "event",
-                                    # "stream": "primary",
-                                    "field": "ccd",
-                                    }, }, }, ]
+        {
+            "name": "test",
+            "version": "42.0.0",
+            "configuration": {},
+            "projection": {
+                "bad_event_field": {
+                    "type": "linked",
+                    "location": "event",
+                    # "stream": "primary",
+                    "field": "ccd",
+                },
+            },
+        },
+    ]
     start["projections"] = bad_event_projections
-    with pytest.raises(ValidationError, ):
+    with pytest.raises(
+        ValidationError,
+    ):
         event_model.schema_validators[event_model.DocumentNames.start].validate(start)
 
 
-@pytest.mark.skipif(skip_json_validations,
-                    reason="projection schema uses draft7 conditions")
+@pytest.mark.skipif(
+    skip_json_validations, reason="projection schema uses draft7 conditions"
+)
 def test_bad_location_field(start):
     bad_event_projections = [
-                {
-                    "name": "test",
-                    "version": "42.0.0",
-                    "configuration": {},
-                    "projection": {
-                                 "bad_event_field": {
-                                    "type": "linked",
-                                    "location": "event",
-                                    # "stream": "primary",
-                                    "field": "ccd",
-                                    }, }, }, ]
+        {
+            "name": "test",
+            "version": "42.0.0",
+            "configuration": {},
+            "projection": {
+                "bad_event_field": {
+                    "type": "linked",
+                    "location": "event",
+                    # "stream": "primary",
+                    "field": "ccd",
+                },
+            },
+        },
+    ]
     start["projections"] = bad_event_projections
-    with pytest.raises(ValidationError, ):
+    with pytest.raises(
+        ValidationError,
+    ):
         event_model.schema_validators[event_model.DocumentNames.start].validate(start)
 
 
-@pytest.mark.skipif(skip_json_validations,
-                    reason="projection schema uses draft7 conditions")
+@pytest.mark.skipif(
+    skip_json_validations, reason="projection schema uses draft7 conditions"
+)
 def test_bad_static_field(start):
     bad_event_projections = [
-                {
-                    "name": "test",
-                    "version": "42.0.0",
-                    "configuration": {},
-                    "projection": {
-                                 "bad_event_field": {
-                                    "type": "static",
-                                    "location": "event",
-                                    # "stream": "primary",
-                                    "field": "ccd",
-                                    }, }, }, ]
+        {
+            "name": "test",
+            "version": "42.0.0",
+            "configuration": {},
+            "projection": {
+                "bad_event_field": {
+                    "type": "static",
+                    "location": "event",
+                    # "stream": "primary",
+                    "field": "ccd",
+                },
+            },
+        },
+    ]
     start["projections"] = bad_event_projections
-    with pytest.raises(ValidationError, ):
+    with pytest.raises(
+        ValidationError,
+    ):
         event_model.schema_validators[event_model.DocumentNames.start].validate(start)
 
 
 valid_projections = [
-            {
-                "name": "test",
-                "version": "42.0.0",
-                "configuration": {},
-                "projection": {
-                    "linked_field": {
-                        "type": "linked",
-                        "location": "event",
-                        "stream": "primary",
-                        "field": "ccd",
-                    },
-                    "calc_field": {
-                        "type": "calculated",
-                        "location": "event",
-                        "field": "calc_field",
-                        "stream": "calc_stream",
-                        "calculation": {
-                            "callable": "pizza.order:slice",
-                            "kwargs": {"toppings": "cheese"}
-                        }
-                    },
-                    "config_field": {
-                        "type": "linked",
-                        "location": "configuration",
-                        "config_index": 0,
-                        "config_device": "camera",
-                        "stream": "primary",
-                        "field": "setting"
-                    },
-                    "static_field": {
-                        "type": "static",
-                        "value": "strcvsdf"
-                    }
+    {
+        "name": "test",
+        "version": "42.0.0",
+        "configuration": {},
+        "projection": {
+            "linked_field": {
+                "type": "linked",
+                "location": "event",
+                "stream": "primary",
+                "field": "ccd",
+            },
+            "calc_field": {
+                "type": "calculated",
+                "location": "event",
+                "field": "calc_field",
+                "stream": "calc_stream",
+                "calculation": {
+                    "callable": "pizza.order:slice",
+                    "kwargs": {"toppings": "cheese"},
                 },
-            }
-        ]
+            },
+            "config_field": {
+                "type": "linked",
+                "location": "configuration",
+                "config_index": 0,
+                "config_device": "camera",
+                "stream": "primary",
+                "field": "setting",
+            },
+            "static_field": {"type": "static", "value": "strcvsdf"},
+        },
+    }
+]
diff --git a/event_model/tests/test_run_router.py b/event_model/tests/test_run_router.py
index 3a3171ea2..0143b74e6 100644
--- a/event_model/tests/test_run_router.py
+++ b/event_model/tests/test_run_router.py
@@ -10,36 +10,46 @@ def test_run_router(tmp_path):
     bundle = event_model.compose_run()
     docs = []
     start_doc, compose_descriptor, compose_resource, compose_stop = bundle
-    docs.append(('start', start_doc))
+    docs.append(("start", start_doc))
     bundle = compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'},
-                   'image': {'shape': [512, 512], 'dtype': 'number',
-                             'source': '...', 'external': 'FILESTORE:'}},
-        name='primary')
+        data_keys={
+            "motor": {"shape": [], "dtype": "number", "source": "..."},
+            "image": {
+                "shape": [512, 512],
+                "dtype": "number",
+                "source": "...",
+                "external": "FILESTORE:",
+            },
+        },
+        name="primary",
+    )
     primary_descriptor_doc, compose_primary_event, compose_event_page = bundle
-    docs.append(('descriptor', primary_descriptor_doc))
+    docs.append(("descriptor", primary_descriptor_doc))
     bundle = compose_descriptor(
-        data_keys={'motor': {'shape': [], 'dtype': 'number', 'source': '...'}},
-        name='baseline')
+        data_keys={"motor": {"shape": [], "dtype": "number", "source": "..."}},
+        name="baseline",
+    )
     baseline_descriptor_doc, compose_baseline_event, compose_event_page = bundle
-    docs.append(('descriptor', baseline_descriptor_doc))
+    docs.append(("descriptor", baseline_descriptor_doc))
     bundle = compose_resource(
-        spec='TIFF', root=str(tmp_path), resource_path='stack.tiff',
-        resource_kwargs={})
+        spec="TIFF", root=str(tmp_path), resource_path="stack.tiff", resource_kwargs={}
+    )
     resource_doc, compose_datum, compose_datum_page = bundle
-    docs.append(('resource', resource_doc))
-    datum_doc = compose_datum(datum_kwargs={'slice': 5})
-    docs.append(('datum', datum_doc))
+    docs.append(("resource", resource_doc))
+    datum_doc = compose_datum(datum_kwargs={"slice": 5})
+    docs.append(("datum", datum_doc))
     primary_event_doc = compose_primary_event(
-        data={'motor': 0, 'image': datum_doc['datum_id']},
-        timestamps={'motor': 0, 'image': 0}, filled={'image': False})
-    docs.append(('event', primary_event_doc))
+        data={"motor": 0, "image": datum_doc["datum_id"]},
+        timestamps={"motor": 0, "image": 0},
+        filled={"image": False},
+    )
+    docs.append(("event", primary_event_doc))
     baseline_event_doc = compose_baseline_event(
-        data={'motor': 0},
-        timestamps={'motor': 0})
-    docs.append(('event', baseline_event_doc))
+        data={"motor": 0}, timestamps={"motor": 0}
+    )
+    docs.append(("event", baseline_event_doc))
     stop_doc = compose_stop()
-    docs.append(('stop', stop_doc))
+    docs.append(("stop", stop_doc))
 
     # Empty list of factories. Just make sure nothing blows up.
     rr = event_model.RunRouter([])
@@ -58,12 +68,12 @@ def null_factory(name, doc):
     collected = []
 
     def collector(name, doc):
-        if name == 'event_page':
-            name = 'event'
-            doc, = event_model.unpack_event_page(doc)
-        elif name == 'datum_page':
-            name = 'datum'
-            doc, = event_model.unpack_datum_page(doc)
+        if name == "event_page":
+            name = "event"
+            (doc,) = event_model.unpack_event_page(doc)
+        elif name == "datum_page":
+            name = "datum"
+            (doc,) = event_model.unpack_datum_page(doc)
         collected.append((name, doc))
 
     def all_factory(name, doc):
@@ -78,7 +88,7 @@ def all_factory(name, doc):
 
     # A factory that returns a subfactory interested in 'baseline' only.
     def subfactory(name, doc):
-        if doc.get('name') == 'baseline':
+        if doc.get("name") == "baseline":
             return [collector]
         return []
 
@@ -89,8 +99,8 @@ def factory_with_subfactory_only(name, doc):
     for name, doc in docs:
         rr(name, doc)
 
-    expected_item = ('event', baseline_event_doc)
-    unexpected_item = ('event', primary_event_doc)
+    expected_item = ("event", baseline_event_doc)
+    unexpected_item = ("event", primary_event_doc)
     assert expected_item in collected
     assert unexpected_item not in collected
     collected.clear()
@@ -103,8 +113,8 @@ class LocalException3(Exception):
         ...
 
     def header_collector(name, doc):
-        if name in ('start', 'stop', 'descriptor'):
-            key = (name, doc['uid'])
+        if name in ("start", "stop", "descriptor"):
+            key = (name, doc["uid"])
             if key in collected_header_docs:
                 raise LocalException3
             collected_header_docs[key] = doc
@@ -114,7 +124,7 @@ def all_factory(name, doc):
         return [header_collector], []
 
     rr = event_model.RunRouter([all_factory])
-    with pytest.warns(UserWarning, match='1.14.0'), pytest.raises(LocalException3):
+    with pytest.warns(UserWarning, match="1.14.0"), pytest.raises(LocalException3):
         for name, doc in docs:
             rr(name, doc)
 
@@ -126,7 +136,7 @@ def factory_with_subfactory_only(name, doc):
         header_collector(name, doc)
 
         def subfactory(name, doc):
-            if doc.get('name') == 'baseline':
+            if doc.get("name") == "baseline":
                 header_collector(name, doc)
                 return [header_collector]
             return []
@@ -134,7 +144,7 @@ def subfactory(name, doc):
         return [], [subfactory]
 
     rr = event_model.RunRouter([factory_with_subfactory_only])
-    with pytest.warns(UserWarning, match='1.14.0'), pytest.raises(LocalException3):
+    with pytest.warns(UserWarning, match="1.14.0"), pytest.raises(LocalException3):
         for name, doc in docs:
             rr(name, doc)
 
@@ -149,22 +159,22 @@ def __init__(self, resource_path):
         def __call__(self, slice):
             return numpy.ones((5, 5))
 
-    reg = {'TIFF': FakeTiffHandler}
+    reg = {"TIFF": FakeTiffHandler}
 
     def check_filled(name, doc):
-        if name == 'event_page':
-            for is_filled in doc['filled'].values():
+        if name == "event_page":
+            for is_filled in doc["filled"].values():
                 assert all(is_filled)
-        elif name == 'event':
-            for is_filled in doc['filled'].values():
+        elif name == "event":
+            for is_filled in doc["filled"].values():
                 assert is_filled
 
     def check_not_filled(name, doc):
-        if name == 'event_page':
-            for is_filled in doc['filled'].values():
+        if name == "event_page":
+            for is_filled in doc["filled"].values():
                 assert not any(is_filled)
-        elif name == 'event':
-            for is_filled in doc['filled'].values():
+        elif name == "event":
+            for is_filled in doc["filled"].values():
                 assert not is_filled
 
     def check_filled_factory(name, doc):
@@ -199,16 +209,25 @@ def check_not_filled_factory(name, doc):
 def test_run_router_streams(tmp_path):
     bundle = event_model.compose_run()
     docs = []
-    start_doc, compose_stream_resource, stop_doc = \
-        bundle.start_doc, bundle.compose_stream_resource, bundle.compose_stop()
+    start_doc, compose_stream_resource, stop_doc = (
+        bundle.start_doc,
+        bundle.compose_stream_resource,
+        bundle.compose_stop(),
+    )
     docs.append(("start", start_doc))
     stream_names = ["stream_1", "stream_2"]
-    stream_resource_doc, compose_stream_data = \
-        compose_stream_resource(spec="TIFF_STREAM", root=str(tmp_path), resource_path="test_streams",
-                                resource_kwargs={}, stream_names=stream_names)
+    stream_resource_doc, compose_stream_data = compose_stream_resource(
+        spec="TIFF_STREAM",
+        root=str(tmp_path),
+        resource_path="test_streams",
+        resource_kwargs={},
+        stream_names=stream_names,
+    )
     docs.append(("stream_resource", stream_resource_doc))
-    datum_doc_0, datum_doc_1 = (compose_stream_datum(datum_kwargs={})
-                                for compose_stream_datum in compose_stream_data)
+    datum_doc_0, datum_doc_1 = (
+        compose_stream_datum(datum_kwargs={})
+        for compose_stream_datum in compose_stream_data
+    )
     docs.append(("stream_datum", datum_doc_0))
     docs.append(("stream_datum", datum_doc_1))
     docs.append(("stop", stop_doc))
@@ -290,15 +309,15 @@ def subfactory(name, descriptor_doc):
 def test_same_start_doc_twice():
     "If the user sends us the same uid twice, raise helpfully."
     rr = event_model.RunRouter([])
-    doc = {'time': 0, 'uid': 'stuff'}
-    rr('start', doc)
+    doc = {"time": 0, "uid": "stuff"}
+    rr("start", doc)
     with pytest.raises(ValueError):
-        rr('start', doc)  # exact same object
+        rr("start", doc)  # exact same object
     with pytest.raises(ValueError):
-        rr('start', doc.copy())  # same content
-    doc2 = {'time': 1, 'uid': 'stuff'}
+        rr("start", doc.copy())  # same content
+    doc2 = {"time": 1, "uid": "stuff"}
     with pytest.raises(ValueError):
-        rr('start', doc2)  # same uid, different content
+        rr("start", doc2)  # same uid, different content
 
 
 def test_subfactory_callback_exception():
@@ -308,31 +327,28 @@ def test_subfactory_callback_exception():
     """
 
     def exception_callback(name, doc):
-        """ a callback that always raises Exception """
+        """a callback that always raises Exception"""
         raise Exception()
 
     def exception_callback_subfactory(descriptor_doc_name, descriptor_doc):
-        """ a subfactory that always returns one exception_callback """
+        """a subfactory that always returns one exception_callback"""
         return [exception_callback]
 
     def exception_callback_factory(start_doc_name, start_doc):
-        """ a factory that return 0 callbacks and one exception_callback_subfactory """
-        return (
-            [],
-            [exception_callback_subfactory]
-        )
+        """a factory that return 0 callbacks and one exception_callback_subfactory"""
+        return ([], [exception_callback_subfactory])
 
     rr = event_model.RunRouter([exception_callback_factory])
 
-    start_doc = {'time': 0, 'uid': 'abcdef'}
-    rr('start', start_doc)
+    start_doc = {"time": 0, "uid": "abcdef"}
+    rr("start", start_doc)
 
-    descriptor_doc = {'run_start': 'abcdef', 'uid': 'ghijkl'}
+    descriptor_doc = {"run_start": "abcdef", "uid": "ghijkl"}
     with pytest.raises(Exception):
-        rr('descriptor', descriptor_doc)
+        rr("descriptor", descriptor_doc)
 
-    assert rr._start_to_descriptors['abcdef'] == ['ghijkl']
-    assert rr._descriptor_to_start['ghijkl'] == 'abcdef'
+    assert rr._start_to_descriptors["abcdef"] == ["ghijkl"]
+    assert rr._descriptor_to_start["ghijkl"] == "abcdef"
 
-    event_document = {'descriptor': 'ghijkl', 'uid': 'mnopqr'}
+    event_document = {"descriptor": "ghijkl", "uid": "mnopqr"}
     rr.event(event_document)