You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@arrow.apache.org by li...@apache.org on 2023/06/13 01:45:11 UTC

[arrow-adbc] branch main updated: docs: add literate-style code recipes (#759)

This is an automated email from the ASF dual-hosted git repository.

lidavidm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-adbc.git


The following commit(s) were added to refs/heads/main by this push:
     new d9e62487 docs: add literate-style code recipes (#759)
d9e62487 is described below

commit d9e624874526348c79221d249cd9e87b77fca6a3
Author: David Li <li...@gmail.com>
AuthorDate: Mon Jun 12 21:45:05 2023 -0400

    docs: add literate-style code recipes (#759)
    
    Adds a custom Sphinx directive that generates further reStructuredText
    markup from a self-contained source file. Appropriately marked comments
    in the source file are turned into markup, and code in between is turned
    into code blocks. This lets us author and test recipes using
    language-native tooling (unlike what the Apache Arrow Cookbook does) but
    still embed them in a readable way inside the Sphinx documentation.
---
 .github/workflows/native-unix.yml                  |  67 +++++++++
 ci/conda_env_docs.txt                              |   1 +
 docs/source/conf.py                                |   6 +
 docs/source/driver/postgresql.rst                  |   2 +
 docs/source/ext/adbc_cookbook.py                   | 161 +++++++++++++++++++++
 docs/source/python/index.rst                       |   1 +
 .../{index.rst => recipe/driver_manager.rst}       |  14 +-
 docs/source/python/recipe/driver_manager_duckdb.py |  44 ++++++
 docs/source/python/{ => recipe}/index.rst          |  12 +-
 .../python/{index.rst => recipe/postgresql.rst}    |  34 ++++-
 .../python/recipe/postgresql_authenticate.py       |  34 +++--
 .../recipe/postgresql_create_append_table.py       |  78 ++++++++++
 .../python/recipe/postgresql_execute_bind.py       |  65 +++++++++
 .../python/recipe/postgresql_get_table_schema.py   |  38 +++--
 .../python/recipe/postgresql_list_catalogs.py      |  55 +++++++
 .../source/tests/test_cookbook.py                  |  28 ++--
 16 files changed, 589 insertions(+), 51 deletions(-)

diff --git a/.github/workflows/native-unix.yml b/.github/workflows/native-unix.yml
index 6b0178f9..7f29477e 100644
--- a/.github/workflows/native-unix.yml
+++ b/.github/workflows/native-unix.yml
@@ -466,11 +466,78 @@ jobs:
         shell: bash -l {0}
         run: |
           env BUILD_ALL=0 BUILD_DRIVER_SNOWFLAKE=1 ./ci/scripts/python_test.sh "$(pwd)" "$(pwd)/build" "$HOME/local"
+
+  python-docs:
+    name: "Documentation ${{ matrix.python }} (Conda/${{ matrix.os }})"
+    runs-on: ${{ matrix.os }}
+    needs:
+      - drivers-build-conda
+    services:
+      postgres:
+        image: postgres
+        env:
+          POSTGRES_DB: postgres
+          POSTGRES_PASSWORD: password
+        options: >-
+          --health-cmd pg_isready
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+        ports:
+          - 5432:5432
+    strategy:
+      matrix:
+        os: ["ubuntu-latest"]
+        python: ["3.11"]
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+      - name: Get Date
+        id: get-date
+        shell: bash
+        run: |
+          echo "today=$(/bin/date -u '+%Y%m%d')" >> $GITHUB_OUTPUT
+      - name: Cache Conda
+        uses: actions/cache@v3
+        with:
+          path: ~/conda_pkgs_dir
+          key: conda-${{ runner.os }}-${{ steps.get-date.outputs.today }}-${{ env.CACHE_NUMBER }}-${{ hashFiles('ci/**') }}
+      - uses: conda-incubator/setup-miniconda@v2
+        with:
+          miniforge-variant: Mambaforge
+          miniforge-version: latest
+          use-only-tar-bz2: false
+          use-mamba: true
+      - name: Install Dependencies
+        shell: bash -l {0}
+        run: |
+          mamba install -c conda-forge \
+            python=${{ matrix.python }} \
+            --file ci/conda_env_docs.txt \
+            --file ci/conda_env_python.txt
+      - uses: actions/download-artifact@v3
+        with:
+          name: driver-manager-${{ matrix.os }}
+          path: ~/local
+
+      - name: Build Python
+        shell: bash -l {0}
+        run: |
+          env BUILD_ALL=1 ./ci/scripts/python_build.sh "$(pwd)" "$(pwd)/build" "$HOME/local"
       # Docs requires Python packages since it runs doctests
       - name: Build Docs
         shell: bash -l {0}
         run: |
           ./ci/scripts/docs_build.sh "$(pwd)"
+      # Docs requires Python packages since it runs doctests
+      - name: Test Docs
+        shell: bash -l {0}
+        env:
+          ADBC_POSTGRESQL_TEST_URI: "postgres://localhost:5432/postgres?user=postgres&password=password"
+        run: |
+          pytest -vvs docs/source/tests/
 
   # ------------------------------------------------------------
   # R
diff --git a/ci/conda_env_docs.txt b/ci/conda_env_docs.txt
index 2443ff8f..105ea10a 100644
--- a/ci/conda_env_docs.txt
+++ b/ci/conda_env_docs.txt
@@ -20,6 +20,7 @@ doxygen
 furo
 make
 numpydoc
+pytest
 sphinx>=5.0
 sphinx-autobuild
 sphinx-copybutton
diff --git a/docs/source/conf.py b/docs/source/conf.py
index d0d7ff9f..ac8404e1 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -15,6 +15,11 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import sys
+from pathlib import Path
+
+sys.path.append(str(Path("./ext").resolve()))
+
 # -- Project information -----------------------------------------------------
 # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
 
@@ -30,6 +35,7 @@ version = release
 
 exclude_patterns = []
 extensions = [
+    "adbc_cookbook",
     "breathe",
     "numpydoc",
     "sphinx.ext.autodoc",
diff --git a/docs/source/driver/postgresql.rst b/docs/source/driver/postgresql.rst
index 570ac81f..b8e43db2 100644
--- a/docs/source/driver/postgresql.rst
+++ b/docs/source/driver/postgresql.rst
@@ -110,6 +110,8 @@ the :cpp:class:`AdbcDatabase`.  This should be a `connection URI
          with adbc_driver_postgresql.dbapi.connect(uri) as conn:
              pass
 
+      For more examples, see :doc:`../python/recipe/postgresql`.
+
    .. tab-item:: R
       :sync: r
 
diff --git a/docs/source/ext/adbc_cookbook.py b/docs/source/ext/adbc_cookbook.py
new file mode 100644
index 00000000..93533e77
--- /dev/null
+++ b/docs/source/ext/adbc_cookbook.py
@@ -0,0 +1,161 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""A directive for code recipes with a literate programming style."""
+
+import typing
+from pathlib import Path
+
+import docutils
+from docutils.parsers.rst import directives
+from docutils.statemachine import StringList
+from sphinx.util.docutils import SphinxDirective
+from sphinx.util.typing import OptionSpec
+
+
+class SourceLine(typing.NamedTuple):
+    content: str
+    lineno: int
+
+
+class SourceFragment(typing.NamedTuple):
+    kind: str
+    lines: list[SourceLine]
+
+
+PREAMBLE = "Recipe source: `{name} <{url}>`_"
+
+
+class RecipeDirective(SphinxDirective):
+    has_content = False
+    required_arguments = 1
+    optional_arguments = 0
+    option_spec: OptionSpec = {
+        "language": directives.unchanged_required,
+        "prose-prefix": directives.unchanged_required,
+    }
+
+    @staticmethod
+    def default_prose_prefix(language: str) -> str:
+        return {
+            "cpp": "///",
+            "python": "#:",
+        }.get(language, "#:")
+
+    def run(self):
+        rel_filename, filename = self.env.relfn2path(self.arguments[0])
+        self.env.note_dependency(rel_filename)
+        self.env.note_dependency(__file__)
+
+        language = self.options.get("language", "python")
+        prefix = self.options.get("prose-prefix", self.default_prose_prefix(language))
+
+        fragments = []
+
+        # Link to the source on GitHub
+        github_url = (
+            f"https://github.com/apache/arrow-adbc/blob/main/docs/source/{rel_filename}"
+        )
+        fragments.append(
+            SourceFragment(
+                kind="prose",
+                lines=[
+                    # lineno doesn't matter for prose
+                    SourceLine(
+                        PREAMBLE.format(
+                            name=Path(rel_filename).name,
+                            url=github_url,
+                        ),
+                        lineno=0,
+                    )
+                ],
+            )
+        )
+
+        fragment = []
+        fragment_type = None
+        state = "before"
+        lineno = 1
+        for line in open(filename):
+            if state == "before":
+                if "RECIPE STARTS HERE" in line:
+                    state = "reading"
+            elif state == "reading":
+                if line.strip().startswith(prefix):
+                    line_type = "prose"
+                    # Remove prefix and next whitespace
+                    line = line[len(prefix) + 1 :]
+                else:
+                    line_type = "code"
+
+                if line_type != fragment_type:
+                    if fragment:
+                        fragments.append(
+                            SourceFragment(kind=fragment_type, lines=fragment)
+                        )
+                        fragment = []
+                    fragment_type = line_type
+
+                # Skip blank code lines
+                if line_type != "code" or line.strip():
+                    # Remove trailing newline
+                    fragment.append(SourceLine(content=line[:-1], lineno=lineno))
+
+            lineno += 1
+
+        if fragment:
+            fragments.append(SourceFragment(kind=fragment_type, lines=fragment))
+
+        nodes = []
+        for fragment in fragments:
+            parsed = docutils.nodes.Element()
+            if fragment.kind == "prose":
+                self.state.nested_parse(
+                    StringList([line.content for line in fragment.lines], source=""),
+                    self.content_offset,
+                    parsed,
+                )
+            elif fragment.kind == "code":
+                line_min = fragment.lines[0].lineno
+                line_max = fragment.lines[-1].lineno
+                lines = [
+                    f".. literalinclude:: {self.arguments[0]}",
+                    "   :linenos:",
+                    "   :lineno-match:",
+                    f"   :lines: {line_min}-{line_max}",
+                    "",
+                ]
+                self.state.nested_parse(
+                    StringList(lines, source=""),
+                    self.content_offset,
+                    parsed,
+                )
+            else:
+                raise RuntimeError("Unknown fragment kind")
+            nodes.extend(parsed.children)
+
+        return nodes
+
+
+def setup(app) -> None:
+    app.add_directive("recipe", RecipeDirective)
+
+    return {
+        "version": "0.1",
+        "parallel_read_safe": True,
+        "parallel_write_safe": True,
+    }
diff --git a/docs/source/python/index.rst b/docs/source/python/index.rst
index 862ee9d4..e2ab22ba 100644
--- a/docs/source/python/index.rst
+++ b/docs/source/python/index.rst
@@ -25,3 +25,4 @@ Python
    quickstart
    driver_manager
    api/index
+   recipe/index
diff --git a/docs/source/python/index.rst b/docs/source/python/recipe/driver_manager.rst
similarity index 80%
copy from docs/source/python/index.rst
copy to docs/source/python/recipe/driver_manager.rst
index 862ee9d4..349d5490 100644
--- a/docs/source/python/index.rst
+++ b/docs/source/python/recipe/driver_manager.rst
@@ -15,13 +15,11 @@
 .. specific language governing permissions and limitations
 .. under the License.
 
-======
-Python
-======
+======================
+Driver Manager Recipes
+======================
 
-.. toctree::
-   :maxdepth: 2
+Load a driver from a shared library (DuckDB)
+============================================
 
-   quickstart
-   driver_manager
-   api/index
+.. recipe:: driver_manager_duckdb.py
diff --git a/docs/source/python/recipe/driver_manager_duckdb.py b/docs/source/python/recipe/driver_manager_duckdb.py
new file mode 100644
index 00000000..84d4af2d
--- /dev/null
+++ b/docs/source/python/recipe/driver_manager_duckdb.py
@@ -0,0 +1,44 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# RECIPE STARTS HERE
+#: The ADBC driver manager can load a driver from a shared library.
+#: For drivers provided by the Arrow project, you don't need to worry
+#: about this; the Python package will take care of this for you.
+#: Other drivers may need configuration, though.  We'll use `DuckDB
+#: <https://duckdb.org>`_ as an example.
+
+import duckdb
+
+import adbc_driver_manager.dbapi
+
+#: The driver manager needs the path to the shared library.  It also
+#: needs the name of the entrypoint function.  Both of these should be
+#: found in the driver's documentation.
+conn = adbc_driver_manager.dbapi.connect(
+    driver=duckdb.__file__,
+    entrypoint="duckdb_adbc_init",
+)
+
+#: Once we provide that, everything else about the connection is the
+#: same as usual.
+
+with conn.cursor() as cur:
+    cur.execute("SELECT 1")
+    assert cur.fetchone() == (1,)
+
+conn.close()
diff --git a/docs/source/python/index.rst b/docs/source/python/recipe/index.rst
similarity index 86%
copy from docs/source/python/index.rst
copy to docs/source/python/recipe/index.rst
index 862ee9d4..b841b7f1 100644
--- a/docs/source/python/index.rst
+++ b/docs/source/python/recipe/index.rst
@@ -15,13 +15,15 @@
 .. specific language governing permissions and limitations
 .. under the License.
 
-======
-Python
-======
+===============
+Python Cookbook
+===============
+
+The cookbook provides task-oriented example code for using ADBC in
+Python.
 
 .. toctree::
    :maxdepth: 2
 
-   quickstart
    driver_manager
-   api/index
+   postgresql
diff --git a/docs/source/python/index.rst b/docs/source/python/recipe/postgresql.rst
similarity index 54%
copy from docs/source/python/index.rst
copy to docs/source/python/recipe/postgresql.rst
index 862ee9d4..dbf28adb 100644
--- a/docs/source/python/index.rst
+++ b/docs/source/python/recipe/postgresql.rst
@@ -15,13 +15,31 @@
 .. specific language governing permissions and limitations
 .. under the License.
 
-======
-Python
-======
+==================
+PostgreSQL Recipes
+==================
 
-.. toctree::
-   :maxdepth: 2
+Authenticate with a username and password
+=========================================
 
-   quickstart
-   driver_manager
-   api/index
+.. recipe:: postgresql_authenticate.py
+
+Create/append to a table from an Arrow table
+============================================
+
+.. recipe:: postgresql_create_append_table.py
+
+Execute a statement with bind parameters
+========================================
+
+.. recipe:: postgresql_execute_bind.py
+
+Get the Arrow schema of a table
+===============================
+
+.. recipe:: postgresql_get_table_schema.py
+
+List catalogs, schemas, and tables
+==================================
+
+.. recipe:: postgresql_list_catalogs.py
diff --git a/ci/conda_env_docs.txt b/docs/source/python/recipe/postgresql_authenticate.py
similarity index 56%
copy from ci/conda_env_docs.txt
copy to docs/source/python/recipe/postgresql_authenticate.py
index 2443ff8f..d0ccd49e 100644
--- a/ci/conda_env_docs.txt
+++ b/docs/source/python/recipe/postgresql_authenticate.py
@@ -15,13 +15,27 @@
 # specific language governing permissions and limitations
 # under the License.
 
-breathe
-doxygen
-furo
-make
-numpydoc
-sphinx>=5.0
-sphinx-autobuild
-sphinx-copybutton
-sphinx-design
-sphinxcontrib-mermaid
+# RECIPE STARTS HERE
+#: To connect to a PostgreSQL database, the username and password must
+#: be provided in the URI.  For example,
+#:
+#: .. code-block:: text
+#:
+#:    postgresql://username:password@hostname:port/dbname
+#:
+#: See the `PostgreSQL documentation
+#: <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING>`_
+#: for full details.
+
+import os
+
+import adbc_driver_postgresql.dbapi
+
+uri = os.environ["ADBC_POSTGRESQL_TEST_URI"]
+conn = adbc_driver_postgresql.dbapi.connect(uri)
+
+with conn.cursor() as cur:
+    cur.execute("SELECT 1")
+    assert cur.fetchone() == (1,)
+
+conn.close()
diff --git a/docs/source/python/recipe/postgresql_create_append_table.py b/docs/source/python/recipe/postgresql_create_append_table.py
new file mode 100644
index 00000000..9b0c66f9
--- /dev/null
+++ b/docs/source/python/recipe/postgresql_create_append_table.py
@@ -0,0 +1,78 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# RECIPE STARTS HERE
+#: ADBC allows creating and appending to database tables using Arrow
+#: tables.
+
+import os
+
+import pyarrow
+
+import adbc_driver_postgresql.dbapi
+
+uri = os.environ["ADBC_POSTGRESQL_TEST_URI"]
+conn = adbc_driver_postgresql.dbapi.connect(uri)
+
+#: For the purposes of testing, we'll first make sure the table
+#: doesn't exist.
+with conn.cursor() as cur:
+    cur.execute("DROP TABLE IF EXISTS example")
+
+#: Now we can create the table.
+with conn.cursor() as cur:
+    data = pyarrow.table(
+        [
+            [1, 2, None, 4],
+        ],
+        schema=pyarrow.schema(
+            [
+                ("ints", "int32"),
+            ]
+        ),
+    )
+    cur.adbc_ingest("example", data, mode="create")
+
+conn.commit()
+
+#: After ingestion, we can fetch the result.
+with conn.cursor() as cur:
+    cur.execute("SELECT * FROM example")
+    assert cur.fetchone() == (1,)
+    assert cur.fetchone() == (2,)
+
+    cur.execute("SELECT COUNT(*) FROM example")
+    assert cur.fetchone() == (4,)
+
+#: If we try to ingest again, it'll fail, because the table already
+#: exists.
+with conn.cursor() as cur:
+    try:
+        cur.adbc_ingest("example", data, mode="create")
+    except conn.OperationalError:
+        pass
+    else:
+        raise RuntimeError("Should have failed!")
+
+#: Instead, we can append to the table.
+with conn.cursor() as cur:
+    cur.adbc_ingest("example", data, mode="append")
+
+    cur.execute("SELECT COUNT(*) FROM example")
+    assert cur.fetchone() == (8,)
+
+conn.close()
diff --git a/docs/source/python/recipe/postgresql_execute_bind.py b/docs/source/python/recipe/postgresql_execute_bind.py
new file mode 100644
index 00000000..f6ad0c67
--- /dev/null
+++ b/docs/source/python/recipe/postgresql_execute_bind.py
@@ -0,0 +1,65 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# RECIPE STARTS HERE
+
+#: ADBC allows using Python and Arrow values as bind parameters.
+#: Right now, the PostgreSQL driver only supports bind parameters
+#: for queries that don't generate result sets.
+
+import os
+
+import pyarrow
+
+import adbc_driver_postgresql.dbapi
+
+uri = os.environ["ADBC_POSTGRESQL_TEST_URI"]
+conn = adbc_driver_postgresql.dbapi.connect(uri)
+
+#: We'll create an example table to test.
+with conn.cursor() as cur:
+    cur.execute("DROP TABLE IF EXISTS example")
+    cur.execute("CREATE TABLE example (ints INT, bigints BIGINT)")
+
+conn.commit()
+
+#: We can bind Python values:
+with conn.cursor() as cur:
+    cur.executemany("INSERT INTO example VALUES ($1, $2)", [(1, 2), (3, 4)])
+
+    cur.execute("SELECT SUM(ints) FROM example")
+    assert cur.fetchone() == (4,)
+
+#: .. note:: If you're used to the format-string style ``%s`` syntax that
+#:           libraries like psycopg use for bind parameters, note that this
+#:           is not supported—only the PostgreSQL-native ``$1`` syntax.
+
+#: We can also bind Arrow values:
+with conn.cursor() as cur:
+    data = pyarrow.record_batch(
+        [
+            [5, 6],
+            [7, 8],
+        ],
+        names=["$1", "$2"],
+    )
+    cur.executemany("INSERT INTO example VALUES ($1, $2)", data)
+
+    cur.execute("SELECT SUM(ints) FROM example")
+    assert cur.fetchone() == (15,)
+
+conn.close()
diff --git a/ci/conda_env_docs.txt b/docs/source/python/recipe/postgresql_get_table_schema.py
similarity index 56%
copy from ci/conda_env_docs.txt
copy to docs/source/python/recipe/postgresql_get_table_schema.py
index 2443ff8f..3f1bae72 100644
--- a/ci/conda_env_docs.txt
+++ b/docs/source/python/recipe/postgresql_get_table_schema.py
@@ -15,13 +15,31 @@
 # specific language governing permissions and limitations
 # under the License.
 
-breathe
-doxygen
-furo
-make
-numpydoc
-sphinx>=5.0
-sphinx-autobuild
-sphinx-copybutton
-sphinx-design
-sphinxcontrib-mermaid
+# RECIPE STARTS HERE
+
+#: ADBC lets you get the schema of a table as an Arrow schema.
+
+import os
+
+import pyarrow
+
+import adbc_driver_postgresql.dbapi
+
+uri = os.environ["ADBC_POSTGRESQL_TEST_URI"]
+conn = adbc_driver_postgresql.dbapi.connect(uri)
+
+#: We'll create an example table to test.
+with conn.cursor() as cur:
+    cur.execute("DROP TABLE IF EXISTS example")
+    cur.execute("CREATE TABLE example (ints INT, bigints BIGINT)")
+
+conn.commit()
+
+assert conn.adbc_get_table_schema("example") == pyarrow.schema(
+    [
+        ("ints", "int32"),
+        ("bigints", "int64"),
+    ]
+)
+
+conn.close()
diff --git a/docs/source/python/recipe/postgresql_list_catalogs.py b/docs/source/python/recipe/postgresql_list_catalogs.py
new file mode 100644
index 00000000..01203f00
--- /dev/null
+++ b/docs/source/python/recipe/postgresql_list_catalogs.py
@@ -0,0 +1,55 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# RECIPE STARTS HERE
+
+#: ADBC allows listing tables, catalogs, and schemas in the database.
+
+import os
+
+import adbc_driver_postgresql.dbapi
+
+uri = os.environ["ADBC_POSTGRESQL_TEST_URI"]
+conn = adbc_driver_postgresql.dbapi.connect(uri)
+
+#: We'll create an example table to look for.
+with conn.cursor() as cur:
+    cur.execute("DROP TABLE IF EXISTS example")
+    cur.execute("CREATE TABLE example (ints INT, bigints BIGINT)")
+
+conn.commit()
+
+#: The data is given as a PyArrow RecordBatchReader.
+objects = conn.adbc_get_objects(depth="all").read_all()
+
+#: We'll convert it to plain Python data for convenience.
+objects = objects.to_pylist()
+catalog = objects[0]
+assert catalog["catalog_name"] == "postgres"
+
+db_schema = catalog["catalog_db_schemas"][0]
+assert db_schema["db_schema_name"] == "public"
+
+tables = db_schema["db_schema_tables"]
+example = [table for table in tables if table["table_name"] == "example"]
+assert len(example) == 1
+example = example[0]
+
+assert example["table_columns"][0]["column_name"] == "ints"
+assert example["table_columns"][1]["column_name"] == "bigints"
+
+conn.close()
diff --git a/ci/conda_env_docs.txt b/docs/source/tests/test_cookbook.py
similarity index 59%
copy from ci/conda_env_docs.txt
copy to docs/source/tests/test_cookbook.py
index 2443ff8f..9093ec17 100644
--- a/ci/conda_env_docs.txt
+++ b/docs/source/tests/test_cookbook.py
@@ -15,13 +15,21 @@
 # specific language governing permissions and limitations
 # under the License.
 
-breathe
-doxygen
-furo
-make
-numpydoc
-sphinx>=5.0
-sphinx-autobuild
-sphinx-copybutton
-sphinx-design
-sphinxcontrib-mermaid
+import importlib
+from pathlib import Path
+
+import pytest
+
+
+def pytest_generate_tests(metafunc) -> None:
+    root = (Path(__file__).parent.parent / "python/recipe/").resolve()
+    recipes = root.rglob("*.py")
+    metafunc.parametrize(
+        "recipe", [pytest.param(path, id=path.stem) for path in recipes]
+    )
+
+
+def test_cookbook_recipe(recipe: Path) -> None:
+    spec = importlib.util.spec_from_file_location(f"cookbook.{recipe.stem}", recipe)
+    mod = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(mod)