diff --git a/bindings/python/MANIFEST.in b/bindings/python/MANIFEST.in
index 459b317..66fe9b8 100644
--- a/bindings/python/MANIFEST.in
+++ b/bindings/python/MANIFEST.in
@@ -3,6 +3,7 @@
 
 include setup.py
 include README.md
+include gpiod-version.txt
 
 recursive-include gpiod *.py
 recursive-include tests *.py
@@ -12,3 +13,7 @@ recursive-include gpiod/ext *.h
 
 recursive-include tests/gpiosim *.c
 recursive-include tests/procname *.c
+
+recursive-include lib *.c
+recursive-include lib *.h
+recursive-include include *.h
diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml
index fcf6bbe..f6bf43c 100644
--- a/bindings/python/pyproject.toml
+++ b/bindings/python/pyproject.toml
@@ -2,4 +2,4 @@
 # SPDX-FileCopyrightText: 2023 Phil Howard <phil@gadgetoid.com>
 
 [build-system]
-requires = ["setuptools", "wheel"]
+requires = ["setuptools", "wheel", "packaging"]
diff --git a/bindings/python/setup.py b/bindings/python/setup.py
index c8db0a0..ac8b241 100644
--- a/bindings/python/setup.py
+++ b/bindings/python/setup.py
@@ -1,24 +1,201 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 # SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
 
-from os import environ, path
-from setuptools import setup, Extension, find_packages
+from os import getenv, path, unlink
+from shutil import copytree, rmtree
+
+from setuptools import Extension, find_packages, setup
 from setuptools.command.build_ext import build_ext as orig_build_ext
-from shutil import rmtree
+from setuptools.command.sdist import log
+from setuptools.command.sdist import sdist as orig_sdist
+from setuptools.errors import BaseError
+
+LINK_SYSTEM_LIBGPIOD = getenv("LINK_SYSTEM_LIBGPIOD") == "1"
+GPIOD_MINIMUM_VERSION = "2.1.0"
+GPIOD_VERSION = getenv("GPIOD_VERSION")
+GPIOD_WITH_TESTS = getenv("GPIOD_WITH_TESTS") == "1"
+SRC_BASE_URL = "https://mirrors.edge.kernel.org/pub/software/libs/libgpiod/"
+TAR_FILENAME = "libgpiod-{version}.tar.gz"
+ASC_FILENAME = "sha256sums.asc"
+SHA256_CHUNK_SIZE = 2048
+
+# __version__
+with open("gpiod/version.py", "r") as fd:
+    exec(fd.read())
+
+
+def sha256(filename):
+    """
+    Return a sha256sum for a specific filename, loading the file in chunks
+    to avoid potentially excessive memory use.
+    """
+    from hashlib import sha256
+
+    sha256sum = sha256()
+    with open(filename, "rb") as f:
+        for chunk in iter(lambda: f.read(SHA256_CHUNK_SIZE), b""):
+            sha256sum.update(chunk)
+
+    return sha256sum.hexdigest()
+
+
+def find_sha256sum(asc_file, tar_filename):
+    """
+    Search through a local copy of sha256sums.asc for a specific filename
+    and return the associated sha256 sum.
+    """
+    with open(asc_file, "r") as f:
+        for line in f:
+            if line[66:-1] == tar_filename:
+                return line.split("  ")[0]
+
+    raise BaseError(f"no signature found for {tar_filename}")
+
+
+def fetch_tarball(command):
+    """
+    Verify the requested GPIOD_VERSION tarball exists in sha256sums.asc,
+    fetch it from https://mirrors.edge.kernel.org/pub/software/libs/libgpiod/
+    and verify its sha256sum.
+
+    If the check passes, extract the tarball and copy the lib and include
+    dirs into our source tree.
+    """
+
+    # If no GPIOD_VERSION is specified in env, just run the command
+    if GPIOD_VERSION is None:
+        return command
+
+    # If GPIOD_VERSION is specified, apply the tarball wrapper
+    def wrapper(self):
+        # Just-in-time import of tarfile and urllib.request so these are
+        # not required for Yocto to build a vendored or linked package
+        import tarfile
+        from tempfile import TemporaryDirectory
+        from urllib.request import urlretrieve
+
+        from packaging.version import Version
+
+        # Early exit for build tree with dirty lib/include dirs
+        for check_dir in "lib", "include":
+            if path.isdir(f"./{check_dir}"):
+                raise BaseError(f"refusing to overwrite ./{check_dir}")
+
+        with TemporaryDirectory(prefix="libgpiod-") as temp_dir:
+            tarball_filename = TAR_FILENAME.format(version=GPIOD_VERSION)
+            tarball_url = f"{SRC_BASE_URL}{tarball_filename}"
+            asc_url = f"{SRC_BASE_URL}{ASC_FILENAME}"
+
+            log.info(f"fetching: {asc_url}")
+
+            asc_filename, _ = urlretrieve(asc_url, path.join(temp_dir, ASC_FILENAME))
+
+            tarball_sha256 = find_sha256sum(asc_filename, tarball_filename)
+
+            if Version(GPIOD_VERSION) < Version(GPIOD_MINIMUM_VERSION):
+                raise BaseError(f"requires gpiod>={GPIOD_MINIMUM_VERSION}")
+
+            log.info(f"fetching: {tarball_url}")
+
+            downloaded_tarball, _ = urlretrieve(
+                tarball_url, path.join(temp_dir, tarball_filename)
+            )
+
+            log.info(f"verifying: {tarball_filename}")
+            if sha256(downloaded_tarball) != tarball_sha256:
+                raise BaseError(f"signature mismatch for {tarball_filename}")
+
+            # Unpack the downloaded tarball
+            log.info(f"unpacking: {tarball_filename}")
+            with tarfile.open(downloaded_tarball) as f:
+                f.extractall(temp_dir)
+
+            # Copy the include and lib directories we need to build libgpiod
+            base_dir = path.join(temp_dir, f"libgpiod-{GPIOD_VERSION}")
+            copytree(path.join(base_dir, "include"), "./include")
+            copytree(path.join(base_dir, "lib"), "./lib")
+
+        # Save the gpiod version for sdist
+        open("gpiod-version.txt", "w").write(GPIOD_VERSION)
+
+        # Run the command
+        command(self)
+
+        # Clean up the build directory
+        rmtree("./lib", ignore_errors=True)
+        rmtree("./include", ignore_errors=True)
+        unlink("gpiod-version.txt")
+
+    return wrapper
 
 
 class build_ext(orig_build_ext):
     """
-    setuptools install all C extentions even if they're excluded in setup().
-    As a workaround - remove the tests directory right after all extensions
-    were built (and possibly copied to the source directory if inplace is set).
+    Wrap build_ext to amend the module sources and settings to build
+    the bindings and gpiod into a combined module when a version is
+    specified and LINK_SYSTEM_LIBGPIOD=1 is not present in env.
+
+    run is wrapped with @fetch_tarball in order to fetch the sources
+    needed to build binary wheels when GPIOD_VERSION is specified, eg:
+
+    GPIOD_VERSION="2.0.2" python3 -m build .
     """
 
+    @fetch_tarball
     def run(self):
+        # Try to get the gpiod version from the .txt file included in sdist
+        try:
+            gpiod_version = open("gpiod-version.txt", "r").read()
+        except OSError:
+            gpiod_version = GPIOD_VERSION
+
+        if gpiod_version and not LINK_SYSTEM_LIBGPIOD:
+            # When building the extension from an sdist with a vendored
+            # amend gpiod._ext sources and settings accordingly.
+            gpiod_ext = self.ext_map["gpiod._ext"]
+            gpiod_ext.sources += [
+                "lib/chip.c",
+                "lib/chip-info.c",
+                "lib/edge-event.c",
+                "lib/info-event.c",
+                "lib/internal.c",
+                "lib/line-config.c",
+                "lib/line-info.c",
+                "lib/line-request.c",
+                "lib/line-settings.c",
+                "lib/misc.c",
+                "lib/request-config.c",
+            ]
+            gpiod_ext.libraries = []
+            gpiod_ext.include_dirs = ["include", "lib", "gpiod/ext"]
+            gpiod_ext.extra_compile_args.append(
+                f'-DGPIOD_VERSION_STR="{gpiod_version}"',
+            )
+
         super().run()
+
+        # We don't ever want the module tests directory in our package
+        # since this might include gpiosim._ext or procname._ext from a
+        # previous dirty build tree.
         rmtree(path.join(self.build_lib, "tests"), ignore_errors=True)
 
 
+class sdist(orig_sdist):
+    """
+    Wrap sdist in order to fetch the libgpiod source files for vendoring
+    into a source distribution.
+
+    run is wrapped with @fetch_tarball in order to fetch the sources
+    needed to build binary wheels when GPIOD_VERSION is specified, eg:
+
+    GPIOD_VERSION="2.0.2" python3 -m build . --sdist
+    """
+
+    @fetch_tarball
+    def run(self):
+        super().run()
+
+
 gpiod_ext = Extension(
     "gpiod._ext",
     sources=[
@@ -50,19 +227,16 @@ procname_ext = Extension(
 )
 
 extensions = [gpiod_ext]
-if environ.get("GPIOD_WITH_TESTS") == "1":
+if GPIOD_WITH_TESTS:
     extensions.append(gpiosim_ext)
     extensions.append(procname_ext)
 
-with open("gpiod/version.py", "r") as fd:
-    exec(fd.read())
-
 setup(
     name="gpiod",
     packages=find_packages(exclude=["tests", "tests.*"]),
     python_requires=">=3.9.0",
     ext_modules=extensions,
-    cmdclass={"build_ext": build_ext},
+    cmdclass={"build_ext": build_ext, "sdist": sdist},
     version=__version__,
     author="Bartosz Golaszewski",
     author_email="brgl@bgdev.pl",
