diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index d1960e1..84cbc1e 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -9,11 +9,11 @@ jobs:
     name: Build docs
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
-        python-version: "3.11"
+        python-version: "3.12"
         cache: pip
         cache-dependency-path: "**/pyproject.toml"
     - name: Install Uqbar
@@ -22,7 +22,7 @@ jobs:
       run: sudo apt-get install --yes graphviz
     - name: Build docs
       run: make docs
-    - uses: actions/upload-artifact@v3
+    - uses: actions/upload-artifact@v4
       with:
         name: docs
         path: ./docs/build/html/
@@ -31,7 +31,7 @@ jobs:
     name: Build source distribution
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Build sdist
       run: pipx run build --sdist
     - name: Build universal wheel
@@ -46,11 +46,11 @@ jobs:
     needs: [build-docs, build-dist]
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/download-artifact@v3
+    - uses: actions/download-artifact@v4
       with:
         name: dist
         path: dist
-    - uses: pypa/gh-action-pypi-publish@v1.5.0
+    - uses: pypa/gh-action-pypi-publish@v1.8.0
       with:
         user: __token__
         password: ${{ secrets.PYPI_API_TOKEN }}
@@ -60,12 +60,12 @@ jobs:
     needs: [upload-to-pypi]
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/download-artifact@v3
+    - uses: actions/download-artifact@v4
       with:
         name: docs
         path: docs
     - name: Clone gh-pages
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
       with:
         path: gh-pages
         ref: gh-pages
diff --git a/pyproject.toml b/pyproject.toml
index 0e220cb..6445b34 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,6 +17,7 @@ classifiers = [
   "Programming Language :: Python :: 3.9",
   "Programming Language :: Python :: 3.10",
   "Programming Language :: Python :: 3.11",
+  "Programming Language :: Python :: 3.12",
 ]
 dependencies = [
   "Sphinx >= 5.3.0",
@@ -60,7 +61,7 @@ skip-magic-trailing-comma = true
 target-version = ["py311"]
 
 [tool.cibuildwheel]
-build = "cp38-* cp39-* cp310-* cp311-*"
+build = "cp38-* cp39-* cp310-* cp311-* cp312-*"
 test-command = ['python -c "import uqbar"']
 
 [tool.isort]
diff --git a/uqbar/_version.py b/uqbar/_version.py
index 27cde40..09e40b5 100644
--- a/uqbar/_version.py
+++ b/uqbar/_version.py
@@ -1,2 +1,2 @@
-__version_info__ = (0, 7, 1)
+__version_info__ = (0, 7, 2)
 __version__ = ".".join(str(x) for x in __version_info__)
diff --git a/uqbar/book/sphinx.py b/uqbar/book/sphinx.py
index a8337d3..5e88741 100644
--- a/uqbar/book/sphinx.py
+++ b/uqbar/book/sphinx.py
@@ -3,12 +3,13 @@ import contextlib
 import importlib
 import inspect
 import itertools
+import logging
 import pickle
 import sqlite3
 import traceback
 from typing import Any, Dict
 
-from docutils.frontend import OptionParser
+from docutils.frontend import get_default_settings
 from docutils.nodes import Element, General, doctest_block, literal_block
 from docutils.parsers.rst import Directive, Parser
 from docutils.parsers.rst.directives import flag, register_directive
@@ -17,6 +18,8 @@ from sphinx.util.nodes import set_source_info
 
 from .console import Console, ConsoleError, ConsoleInput, ConsoleOutput
 
+logger = logging.getLogger(__name__)
+
 try:
     import black
 
@@ -357,7 +360,7 @@ def parse_rst(rst_string):
     parser = Parser()
     for name, class_ in []:  # Add custom directives here
         register_directive(name, class_)
-    settings = OptionParser(components=(Parser,)).get_default_values()
+    settings = get_default_settings(Parser)
     document = new_document("test", settings)
     parser.parse(rst_string, document)
     document = parser.document
diff --git a/uqbar/io.py b/uqbar/io.py
index 13c7d8d..26d6e41 100644
--- a/uqbar/io.py
+++ b/uqbar/io.py
@@ -6,12 +6,12 @@ import cProfile
 import collections
 import io
 import os
-import pathlib
 import platform
 import pstats
 import subprocess
 import sys
 import time
+from pathlib import Path
 from typing import Generator, List, Optional, Sequence, Tuple, Union
 
 
@@ -25,7 +25,7 @@ class DirectoryChange:
     ### INITIALIZER ###
 
     def __init__(self, directory, verbose=False):
-        directory = pathlib.Path(directory).resolve().absolute()
+        directory = Path(directory).resolve().absolute()
         if not directory.is_dir():
             directory = directory.parent
         self._directory = directory
@@ -37,7 +37,7 @@ class DirectoryChange:
     def __enter__(self):
         if self.verbose:
             print("Changing directory to {} ...".format(self.directory))
-        self._directory_stack.append(pathlib.Path.cwd())
+        self._directory_stack.append(Path.cwd())
         os.chdir(str(self._directory))
         return self
 
@@ -227,18 +227,16 @@ class Timer:
         return self._verbose
 
 
-def find_common_prefix(
-    paths: Sequence[Union[str, pathlib.Path]]
-) -> Optional[pathlib.Path]:
+def find_common_prefix(paths: Sequence[Union[str, Path]]) -> Optional[Path]:
     """
     Find the common prefix of two or more paths.
 
     ::
 
-        >>> import pathlib
-        >>> one = pathlib.Path('foo/bar/baz')
-        >>> two = pathlib.Path('foo/quux/biz')
-        >>> three = pathlib.Path('foo/quux/wuux')
+        >>> from pathlib import Path
+        >>> one = Path('foo/bar/baz')
+        >>> two = Path('foo/quux/biz')
+        >>> three = Path('foo/quux/wuux')
 
     ::
 
@@ -250,7 +248,7 @@ def find_common_prefix(
     """
     counter: collections.Counter = collections.Counter()
     for path in paths:
-        path = pathlib.Path(path)
+        path = Path(path)
         counter.update([path])
         counter.update(path.parents)
     valid_paths = sorted(
@@ -272,21 +270,19 @@ def find_executable(name: str, flags=os.X_OK) -> List[str]:
     """
     result = []
     extensions = [x for x in os.environ.get("PATHEXT", "").split(os.pathsep) if x]
-    path = os.environ.get("PATH", None)
-    if path is None:
-        return []
-    for path in os.environ.get("PATH", "").split(os.pathsep):
-        path = os.path.join(path, name)
-        if os.access(path, flags):
-            result.append(path)
+    for directory_name in os.environ.get("PATH", "").split(os.pathsep):
+        executable_path = Path(directory_name) / name
+        executable_path = executable_path.resolve().absolute()
+        if os.access(executable_path, flags):
+            result.append(str(executable_path))
         for extension in extensions:
-            path_extension = path + extension
-            if os.access(path_extension, flags):
-                result.append(path_extension)
+            extension_path = executable_path.with_suffix(extension)
+            if os.access(extension_path, flags):
+                result.append(str(extension_path))
     return result
 
 
-def open_path(path: pathlib.Path) -> None:
+def open_path(path: Path) -> None:
     if platform.system() == "Darwin":
         subprocess.run(["open", str(path)], check=True)
     elif platform.system() == "Linux":
@@ -295,9 +291,7 @@ def open_path(path: pathlib.Path) -> None:
         os.startfile(str(path))  # type: ignore
 
 
-def relative_to(
-    source_path: Union[str, pathlib.Path], target_path: Union[str, pathlib.Path]
-) -> pathlib.Path:
+def relative_to(source_path: Union[str, Path], target_path: Union[str, Path]) -> Path:
     """
     Generates relative path from ``source_path`` to ``target_path``.
 
@@ -305,9 +299,9 @@ def relative_to(
 
     ::
 
-        >>> import os, pathlib
-        >>> source = pathlib.Path('foo/bar/baz')
-        >>> target = pathlib.Path('foo/quux/biz')
+        >>> from pathlib import Path
+        >>> source = Path('foo/bar/baz')
+        >>> target = Path('foo/quux/biz')
 
     ::
 
@@ -318,6 +312,7 @@ def relative_to(
 
     ::
 
+        >>> import os
         >>> import uqbar.io
         >>> str(uqbar.io.relative_to(source, target)).replace(os.path.sep, "/")
         '../../quux/biz'
@@ -325,34 +320,32 @@ def relative_to(
     :param source_path: the source path
     :param target_path: the target path
     """
-    source_path = pathlib.Path(source_path).absolute()
+    source_path = Path(source_path).absolute()
     if source_path.is_file():
         source_path = source_path.parent
-    target_path = pathlib.Path(target_path).absolute()
+    target_path = Path(target_path).absolute()
     common_prefix = find_common_prefix([source_path, target_path])
     if not common_prefix:
         raise ValueError("No common prefix")
     source_path = source_path.relative_to(common_prefix)
     target_path = target_path.relative_to(common_prefix)
-    result = pathlib.Path(*[".."] * len(source_path.parts))
+    result = Path(*[".."] * len(source_path.parts))
     return result / target_path
 
 
 def walk(
-    root_path: Union[str, pathlib.Path], top_down: bool = True
-) -> Generator[
-    Tuple[pathlib.Path, Sequence[pathlib.Path], Sequence[pathlib.Path]], None, None
-]:
+    root_path: Union[str, Path], top_down: bool = True
+) -> Generator[Tuple[Path, Sequence[Path], Sequence[Path]], None, None]:
     """
     Walks a directory tree.
 
-    Like :py:func:`os.walk` but yielding instances of :py:class:`pathlib.Path`
+    Like :py:func:`os.walk` but yielding instances of :py:class:`Path`
     instead of strings.
 
     :param root_path: foo
     :param top_down: bar
     """
-    root_path = pathlib.Path(root_path)
+    root_path = Path(root_path)
     directory_paths, file_paths = [], []
     for path in sorted(root_path.iterdir()):
         if path.is_dir():
@@ -368,10 +361,7 @@ def walk(
 
 
 def write(
-    contents: str,
-    path: Union[str, pathlib.Path],
-    verbose: bool = False,
-    logger_func=None,
+    contents: str, path: Union[str, Path], verbose: bool = False, logger_func=None
 ) -> bool:
     """
     Writes ``contents`` to ``path``.
@@ -386,7 +376,7 @@ def write(
     :param verbose: whether to print output
     """
     print_func = logger_func or print
-    path = pathlib.Path(path)
+    path = Path(path)
     printed_path = str(path).replace(os.path.sep, "/")  # same display on Windows
     if path.exists():
         old_contents = path.read_text()
diff --git a/uqbar/objects.py b/uqbar/objects.py
index 59d6c37..08f2702 100644
--- a/uqbar/objects.py
+++ b/uqbar/objects.py
@@ -197,8 +197,8 @@ def get_vars(expr):
 
     ::
 
-        >>> args
-        OrderedDict([('arg1', 'a'), ('arg2', 'b')])
+        >>> dict(args)
+        {'arg1': 'a', 'arg2': 'b'}
 
     ::
 

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2e84441..d4079fa 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -20,11 +20,11 @@ jobs:
     name: Build docs
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
-        python-version: "3.11"
+        python-version: "3.12"
         cache: pip
         cache-dependency-path: "**/pyproject.toml"
     - name: Install Uqbar
@@ -38,11 +38,11 @@ jobs:
     name: Lint
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
-        python-version: "3.11"
+        python-version: "3.12"
         cache: pip
         cache-dependency-path: "**/pyproject.toml"
     - name: Install Uqbar
@@ -61,16 +61,16 @@ jobs:
       fail-fast: false
       matrix:
         os: [ubuntu-latest]
-        python-version: ["3.8", "3.9", "3.10", "3.11"]
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
         include:
         - os: macos-latest
-          python-version: "3.10"
+          python-version: "3.11"
         - os: windows-latest
           python-version: "3.11"
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
         cache: pip
diff --git a/tests/conftest.py b/tests/conftest.py
index c4414e1..f9974d7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,9 +1,8 @@
-import os
-import pathlib
 import shutil
+import sys
+from pathlib import Path
 
 import pytest
-from sphinx.testing.path import path
 
 pytest_plugins = "sphinx.testing.fixtures"
 
@@ -15,16 +14,20 @@ collect_ignore = ["roots"]
 def remove_sphinx_projects(sphinx_test_tempdir):
     # Even upon exception, remove any directory from temp area
     # which looks like a Sphinx project. This ONLY runs once.
-    roots_path = pathlib.Path(sphinx_test_tempdir)
-    for d in roots_path.iterdir():
-        if d.is_dir():
-            if pathlib.Path(d, "_build").exists():
+    roots_path = Path(sphinx_test_tempdir)
+    for path in roots_path.iterdir():
+        if path.is_dir():
+            if (Path(path) / "_build").exists():
                 # This directory is a Sphinx project, remove it
-                shutil.rmtree(str(d))
-    yield
+                shutil.rmtree(path)
 
 
 @pytest.fixture()
 def rootdir(remove_sphinx_projects):
-    roots = path(os.path.dirname(__file__) or ".").abspath() / "roots"
-    yield roots
+    root_path = Path(__file__).parent / "roots"
+    if sys.version_info > (3, 8):
+        from sphinx.testing.path import path
+
+        yield path(str(root_path))
+    else:
+        yield root_path
diff --git a/tests/test_io_find_executable.py b/tests/test_io_find_executable.py
index a915807..030af81 100644
--- a/tests/test_io_find_executable.py
+++ b/tests/test_io_find_executable.py
@@ -1,12 +1,17 @@
 import platform
 import sys
+from pathlib import Path
 
 import uqbar.io
 
 
 def test_find_executable():
+    print(f"{sys.executable=}")
     file_name = "python"
     if platform.system() == "Windows":
         file_name = "python.exe"
     found = uqbar.io.find_executable(file_name)
-    assert any(sys.executable.startswith(_) for _ in found)
+    sys_executable = str(Path(sys.executable).resolve())
+    print(f"{sys_executable=}")
+    print(f"{found=}")
+    assert sys_executable in found
diff --git a/tests/test_sphinx_book.py b/tests/test_sphinx_book.py
index 1659576..89458f5 100644
--- a/tests/test_sphinx_book.py
+++ b/tests/test_sphinx_book.py
@@ -1,4 +1,3 @@
-import os
 import pathlib
 import shutil
 import sys
@@ -576,15 +575,13 @@ def test_sphinx_book_text_broken_strict(app, status, warning, rm_dirs):
         )
     )
     assert normalize(ansi_escape(warning.getvalue())) == normalize(
-        """
-        {srcdir}index.rst:15: WARNING:
+        f"""
+        {app.srcdir / "index.rst"}:15: WARNING:
             <literal_block xml:space="preserve">>>> print(this_name_does_not_exist)</literal_block>
             Traceback (most recent call last):
               File "<stdin>", line 1, in <module>
             NameError: name 'this_name_does_not_exist' is not defined
-        """.format(
-            srcdir=app.srcdir + os.path.sep
-        )
+        """
     )
 
 

 Sphinx 8/9 warning and deprecation warning
```bash
tests/conftest.py:6
  /build/uqbar-0.7.1/tests/conftest.py:6: RemovedInSphinx90Warning: 'sphinx.testing.path' is deprecated. Use 'os.path' or 'pathlib' instead.
    from sphinx.testing.path import path

tests/test_book_sphinx.py: 11 warnings
  /build/uqbar-0.7.1/uqbar/book/sphinx.py:360: DeprecationWarning: The frontend.OptionParser class will be replaced by a subclass of argparse.ArgumentParser in Docutils 0.21 or later.
    settings = OptionParser(components=(Parser,)).get_default_values()

tests/test_book_sphinx.py: 759 warnings
  /nix/store/y4dxr00qg40pwgxx9nxj61091zk8bsvl-python3-3.12.1/lib/python3.12/optparse.py:1000: DeprecationWarning: The frontend.Option class will be removed in Docutils 0.21 or later.
    option = self.option_class(*args, **kwargs)

tests/test_sphinx_book.py::test_sphinx_book_text_broken_strict
  /build/uqbar-0.7.1/tests/test_sphinx_book.py:578: RemovedInSphinx80Warning: Sphinx 8 will drop support for representing paths as strings. Use "pathlib.Path" or "os.fspath" instead.
    assert normalize(ansi_escape(warning.getvalue())) == normalize(
```
tests/test_io_find_executable.py::test_find_executable
tests/test_sphinx_book.py::test_sphinx_book_latex_cached tests/test_sphinx_book.py::test_sphinx_book_text_cached tests/test_sphinx_book.py::test_sphinx_book_text_uncached tests/test_sphinx_book.py::test_sphinx_book_text_broken_setup tests/test_sphinx_book.py::test_sphinx_book_text_broken_teardown