Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
Create `dist-info` with CMake built Python packages
## What

Provide a skeleton `dist-info` along side the `openassetio` Python package in `dist`.

## Why

`openassetio` is an "interesting" dependency. It should generally be provide by the Host (as they're the ones initializing/using the API). However it is also a dependency of middleware like traits packages, and manager plugins.

In a simple, pure Python world, all components could list it as a dependency, and something like `pip` would install the right version, and flag any issues.

However, we are not in a simple pure Python world. Many hosts are C++, and so manage the dependency in a different way.

This poses a problem, as a C++ host, that provides a Python interpreter needs to single-source `openassetio` between C++ and Python using the dynamic library build (to avoid multiple copies of `openassetio` being loaded simultaneously, with potentially conflicting versions).

Presently, we don't provide a `dist-info` with our CMake build Python package. If we add a hard dependency on `openassetio` in a manager, when the manager is installed, the package manager can't see that `openassetio` is already satisfied, and instead installs its own (static linked) version, with out any regard to the version pin imposed by the host. This would mean that data objects returned by the manager may not be runtime compatible with the host.

Long-term, hosts may opt to use the C isolation layer, which would mitigate this issue (at the expense of a copy). We're not there yet, and so we need to ensure that an dependency check as part of a package install is able to 'see' the existing `openassetio` installation provided by a host with its embedded interpreter.

By providing a `dist-info` directory with our manual builds, it will allow package managers to determine if the fixed host provided package is compatible with the versioning requirements of the other packages being installed, and stop a second, potentially conflicting version being installed.

The same is true for MediaCreation.

## Acceptance Criteria

- CMake builds of the Python package include a `dist-info` directory with:
  - A minimal `METADATA` required by downstream tooling (or more if needed). 
  - An `INSTALLER` entry that lists `cmake`
  - No `RECORD` to [prevent other package managers from trying to uninstall it](https://packaging.python.org/en/latest/specifications/recording-installed-packages/#intentionally-preventing-changes-to-installed-packages) as part of a dependency upgrade.
  - A CMake option to disable `dist-info` generation.
- When this build is manually installed to a python interpreter, then`-m pip` installing a package with:
  - ... a compatible `openassetio` dependency, using that interpreter, sees the existing installation and considers the dependency satisfied.
  - ... an incompatible `openassetio` dependency errors, and does not attempt to uninstall/update the package
  - Tests that `pip`can see the package and version, and un-installation fails.
Platform agnostic CI build configuration
## What

Some ideas I wanted to capture before I forgot 

* Use MSVC CMake generator on Windows.
* Use bash shell.
* Remove unnecessary bootstrapping.

## Why

We spend too much time waiting for CI runs when we could be doing more productive things. We should have an easy time of making changes and reusing bits of CI across workflows/repos, so should simplify it where possible.

* Using the MSVC generator means we can remove the `vcvarsall` dance. 
  - This is also a pre-requisite to using `bash` in Windows, since `vcvarsall.bat` cannot be called in bash.
  - To allow cross-platform `cmake` command lines, we should use the "Ninja Multi-Config" generator for non-Windows platforms, and get in the habit of passing `--config Release` to `cmake` commands. This includes CMake targets that call `cmake --build` as a subprocess (otherwise `--build` will build _all_ configurations, rather than the target configuration).
* It turns out `bash` is installed on Windows runners, so we can remove some `cmd`-specific conditional steps.
* ~~We removed the `cpython` Conan package dependency, but still have a bunch of steps in the `bootstrap` shell scripts specifically to accommodate it.~~

<!---
## Acceptance Criteria

An exhaustive list of falsifiable outcomes that define fully whether
this task is complete.

Remember to consider:

- Release note updates.
- Documentation updates.
- Additional tests or test updates.
- Impact on dependent repos.
--->

## Notes


See https://github.com/OpenAssetIO/OpenAssetIO/pull/1183/files for examples

Create `dist-info` with CMake built Python packages
## What

Provide a skeleton `dist-info` along side the `openassetio` Python package in `dist`.

## Why

`openassetio` is an "interesting" dependency. It should generally be provide by the Host (as they're the ones initializing/using the API). However it is also a dependency of middleware like traits packages, and manager plugins.

In a simple, pure Python world, all components could list it as a dependency, and something like `pip` would install the right version, and flag any issues.

However, we are not in a simple pure Python world. Many hosts are C++, and so manage the dependency in a different way.

This poses a problem, as a C++ host, that provides a Python interpreter needs to single-source `openassetio` between C++ and Python using the dynamic library build (to avoid multiple copies of `openassetio` being loaded simultaneously, with potentially conflicting versions).

Presently, we don't provide a `dist-info` with our CMake build Python package. If we add a hard dependency on `openassetio` in a manager, when the manager is installed, the package manager can't see that `openassetio` is already satisfied, and instead installs its own (static linked) version, with out any regard to the version pin imposed by the host. This would mean that data objects returned by the manager may not be runtime compatible with the host.

Long-term, hosts may opt to use the C isolation layer, which would mitigate this issue (at the expense of a copy). We're not there yet, and so we need to ensure that an dependency check as part of a package install is able to 'see' the existing `openassetio` installation provided by a host with its embedded interpreter.

By providing a `dist-info` directory with our manual builds, it will allow package managers to determine if the fixed host provided package is compatible with the versioning requirements of the other packages being installed, and stop a second, potentially conflicting version being installed.

The same is true for MediaCreation.

## Acceptance Criteria

- CMake builds of the Python package include a `dist-info` directory with:
  - A minimal `METADATA` required by downstream tooling (or more if needed). 
  - An `INSTALLER` entry that lists `cmake`
  - No `RECORD` to [prevent other package managers from trying to uninstall it](https://packaging.python.org/en/latest/specifications/recording-installed-packages/#intentionally-preventing-changes-to-installed-packages) as part of a dependency upgrade.
  - A CMake option to disable `dist-info` generation.
- When this build is manually installed to a python interpreter, then`-m pip` installing a package with:
  - ... a compatible `openassetio` dependency, using that interpreter, sees the existing installation and considers the dependency satisfied.
  - ... an incompatible `openassetio` dependency errors, and does not attempt to uninstall/update the package
  - Tests that `pip`can see the package and version, and un-installation fails.


Discussion / hints:
I did a `pip install openassetio` and took a look at the `openassetio-1.0.0a14.dist-info` directory.  There are a few metadata files in there, with docs saying what they are rather scattered around the web.  So some useful links

- `METADATA`, `RECORD` and `INSTALLER` [^1] [^2]
- `REQUESTED`  [^2]
-  `top_level.txt` [^3]
- `WHEEL`  [^4] 

[^1]: https://packaging.python.org/en/latest/specifications/recording-installed-packages
[^2]: https://peps.python.org/pep-0376/
[^3]: https://setuptools.pypa.io/en/latest/deprecated/python_eggs.html#top-level-txt-conflict-management-metadata
[^4]: https://peps.python.org/pep-0427/

I did a `pip install openassetio` and took a look at the `openassetio-1.0.0a14.dist-info` directory.  There are a few metadata files in there, with docs saying what they are rather scattered around the web.  So some useful links

- `METADATA`, `RECORD` and `INSTALLER` [^1] [^2]
- `REQUESTED`  [^2]
-  `top_level.txt` [^3]
- `WHEEL`  [^4] 

[^1]: https://packaging.python.org/en/latest/specifications/recording-installed-packages
[^2]: https://peps.python.org/pep-0376/
[^3]: https://setuptools.pypa.io/en/latest/deprecated/python_eggs.html#top-level-txt-conflict-management-metadata
[^4]: https://peps.python.org/pep-0427/

Fix patch:
diff --git a/.github/workflows/build-instructions.yml b/.github/workflows/build-instructions.yml
index 68fb49873..14929a897 100644
--- a/.github/workflows/build-instructions.yml
+++ b/.github/workflows/build-instructions.yml
@@ -37,7 +37,7 @@ jobs:
         docker run -v `pwd`:/src ghcr.io/openassetio/openassetio-build bash -c '
           cd /src && \
           cmake -S . -B build -DOPENASSETIO_ENABLE_TESTS=ON && \
-          ctest --test-dir build'
+          ctest --test-dir build --output-on-failure'
 
   pip_install:
     name: Pip install on  ${{ matrix.config.os }}
@@ -116,3 +116,40 @@ jobs:
 
     - name: Run Python tests
       run: pytest src/openassetio-python/tests/package
+
+  disallowed_pip_install:
+    name: Disallowed pip install on  ${{ matrix.config.os }}
+    runs-on: ${{ matrix.config.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        config:
+          - os: windows-2019
+          - os: ubuntu-20.04
+          - os: macos-11
+    defaults:
+      run:
+        shell: bash
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Bootstrap
+        uses: ./.github/bootstrap_platform
+
+      - name: Build and install via CMake
+        run: >
+          cmake -S . -B build
+          --install-prefix '${{ env.Python_ROOT_DIR }}'
+          --toolchain '${{ github.workspace }}/.conan/conan_paths.cmake'
+
+          cmake --build build --parallel --config Release
+
+          cmake --install build --config Release
+
+      - name: Attempt to install using pip
+        # GitHub sets `pipefail`, so if either the pip install succeeds
+        # (inverted via `!`) or the grep fails, then the step will fail.
+        run: >
+          ! python -m pip install --upgrade --force-reinstall openassetio 2>&1
+          | grep "The package was installed by cmake"
diff --git a/CMakeLists.txt b/CMakeLists.txt
index d59406852..5b5b6a1ef 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -120,6 +120,12 @@ if (OPENASSETIO_ENABLE_PYTHON)
         ""
         CACHE STRING
         "Override default Python module install directory, relative to CMAKE_INSTALL_PREFIX")
+
+    set(OPENASSETIO_ENABLE_PYTHON_INSTALL_DIST_INFO_desc
+        "Create a dist-info metadata directory alongside Python installation to provide"
+        " discoverability and prevent overwrite by package managers such as pip")
+    option(OPENASSETIO_ENABLE_PYTHON_INSTALL_DIST_INFO
+        "${OPENASSETIO_ENABLE_PYTHON_INSTALL_DIST_INFO_desc}" ON)
 endif ()
 
 # Enable C bindings, built as a separate library that depends on the
@@ -262,13 +268,13 @@ write_basic_package_version_file(
 )
 
 configure_package_config_file(
-    cmake/Variables.cmake.in
+    cmake/packaging/Variables.cmake.in
     ${_variables_config_file}
     INSTALL_DESTINATION ${_config_install_dir}
 )
 
 configure_package_config_file(
-    cmake/Config.cmake.in
+    cmake/packaging/Config.cmake.in
     ${_project_config_file}
     INSTALL_DESTINATION ${_config_install_dir}
     # TODO(DF): PATH_VARS?
@@ -297,35 +303,36 @@ message(STATUS "Configuring ${PROJECT_NAME} ${PROJECT_VERSION}")
 message(STATUS "${PROJECT_DESCRIPTION}")
 message(STATUS "CMake ${CMAKE_VERSION}")
 if (CMAKE_TOOLCHAIN_FILE)
-    message(STATUS "Using toolchain file            = ${CMAKE_TOOLCHAIN_FILE}")
+    message(STATUS "Using toolchain file               = ${CMAKE_TOOLCHAIN_FILE}")
 endif ()
-message(STATUS "Project build dir               = ${PROJECT_BINARY_DIR}")
-message(STATUS "Install prefix                  = ${CMAKE_INSTALL_PREFIX}")
-message(STATUS "Configuration types             = ${CMAKE_CONFIGURATION_TYPES}")
-message(STATUS "Build type                      = ${CMAKE_BUILD_TYPE}")
-message(STATUS "Build shared libs               = ${BUILD_SHARED_LIBS}")
-message(STATUS "Enable Python module build      = ${OPENASSETIO_ENABLE_PYTHON}")
+message(STATUS "Project build dir                  = ${PROJECT_BINARY_DIR}")
+message(STATUS "Install prefix                     = ${CMAKE_INSTALL_PREFIX}")
+message(STATUS "Configuration types                = ${CMAKE_CONFIGURATION_TYPES}")
+message(STATUS "Build type                         = ${CMAKE_BUILD_TYPE}")
+message(STATUS "Build shared libs                  = ${BUILD_SHARED_LIBS}")
+message(STATUS "Enable Python module build         = ${OPENASSETIO_ENABLE_PYTHON}")
 if (OPENASSETIO_ENABLE_PYTHON)
-    message(STATUS "Python relative install dir     = ${OPENASSETIO_PYTHON_SITEDIR}")
+    message(STATUS "Python install .dist-info metadata = ${OPENASSETIO_ENABLE_PYTHON_INSTALL_DIST_INFO}")
+    message(STATUS "Python relative install dir        = ${OPENASSETIO_PYTHON_SITEDIR}")
 endif ()
-message(STATUS "Create test targets             = ${OPENASSETIO_ENABLE_TESTS}")
-message(STATUS "Create Python venv during tests = ${OPENASSETIO_ENABLE_PYTHON_TEST_VENV}")
-message(STATUS "Warnings as errors              = ${OPENASSETIO_WARNINGS_AS_ERRORS}")
-message(STATUS "Interprocedural optimization    = ${OPENASSETIO_ENABLE_IPO}")
-message(STATUS "Enable PIC for static libs      = ${OPENASSETIO_ENABLE_POSITION_INDEPENDENT_CODE}")
+message(STATUS "Create test targets                = ${OPENASSETIO_ENABLE_TESTS}")
+message(STATUS "Create Python venv during tests    = ${OPENASSETIO_ENABLE_PYTHON_TEST_VENV}")
+message(STATUS "Warnings as errors                 = ${OPENASSETIO_WARNINGS_AS_ERRORS}")
+message(STATUS "Interprocedural optimization       = ${OPENASSETIO_ENABLE_IPO}")
+message(STATUS "Enable PIC for static libs         = ${OPENASSETIO_ENABLE_POSITION_INDEPENDENT_CODE}")
 if (IS_GCC_OR_CLANG)
-    message(STATUS "New C++11 ABI for gcc           = ${OPENASSETIO_GLIBCXX_USE_CXX11_ABI}")
-    message(STATUS "RUNPATH rather than RPATH       = ${OPENASSETIO_ENABLE_NEW_DTAGS}")
-    message(STATUS "Coverage reports                = ${OPENASSETIO_ENABLE_COVERAGE}")
-    message(STATUS "Sanitizer: Address              = ${OPENASSETIO_ENABLE_SANITIZER_ADDRESS}")
-    message(STATUS "Sanitizer: Leak                 = ${OPENASSETIO_ENABLE_SANITIZER_LEAK}")
-    message(STATUS "Sanitizer: Undefined behavior   = ${OPENASSETIO_ENABLE_SANITIZER_UNDEFINED_BEHAVIOR}")
-    message(STATUS "Sanitizer: Thread               = ${OPENASSETIO_ENABLE_SANITIZER_THREAD}")
-    message(STATUS "Sanitizer: Memory               = ${OPENASSETIO_ENABLE_SANITIZER_MEMORY}")
+    message(STATUS "New C++11 ABI for gcc              = ${OPENASSETIO_GLIBCXX_USE_CXX11_ABI}")
+    message(STATUS "RUNPATH rather than RPATH          = ${OPENASSETIO_ENABLE_NEW_DTAGS}")
+    message(STATUS "Coverage reports                   = ${OPENASSETIO_ENABLE_COVERAGE}")
+    message(STATUS "Sanitizer: Address                 = ${OPENASSETIO_ENABLE_SANITIZER_ADDRESS}")
+    message(STATUS "Sanitizer: Leak                    = ${OPENASSETIO_ENABLE_SANITIZER_LEAK}")
+    message(STATUS "Sanitizer: Undefined behavior      = ${OPENASSETIO_ENABLE_SANITIZER_UNDEFINED_BEHAVIOR}")
+    message(STATUS "Sanitizer: Thread                  = ${OPENASSETIO_ENABLE_SANITIZER_THREAD}")
+    message(STATUS "Sanitizer: Memory                  = ${OPENASSETIO_ENABLE_SANITIZER_MEMORY}")
 endif ()
-message(STATUS "Linter: clang-tidy              = ${OPENASSETIO_ENABLE_CLANG_TIDY} [${OPENASSETIO_CLANGTIDY_EXE}]")
-message(STATUS "Linter: cpplint                 = ${OPENASSETIO_ENABLE_CPPLINT} [${OPENASSETIO_CPPLINT_EXE}]")
-message(STATUS "Linter: clang-format            = ${OPENASSETIO_ENABLE_CLANG_FORMAT} [${OPENASSETIO_CLANGFORMAT_EXE}]")
+message(STATUS "Linter: clang-tidy                 = ${OPENASSETIO_ENABLE_CLANG_TIDY} [${OPENASSETIO_CLANGTIDY_EXE}]")
+message(STATUS "Linter: cpplint                    = ${OPENASSETIO_ENABLE_CPPLINT} [${OPENASSETIO_CPPLINT_EXE}]")
+message(STATUS "Linter: clang-format               = ${OPENASSETIO_ENABLE_CLANG_FORMAT} [${OPENASSETIO_CLANGFORMAT_EXE}]")
 
 #-----------------------------------------------------------------------
 # Recurse to library targets
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 9eac40aa5..d76d11856 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -60,6 +60,14 @@ v1.0.0-alpha.xx
 - Improved error messaging when `defaultManagerForInterface` points to a
   directory not a file.
 
+- Added some protection for accidental overwrites of a CMake installed
+  `openassetio` Python package, by installing a `.dist-info` metadata
+  directory alongside the package. `pip install` will then fail/warn
+  against accidental overwrites/overrides. Added a CMake
+  variable `OPENASSETIO_ENABLE_PYTHON_INSTALL_DIST_INFO` to disable this
+  feature.
+  [#1088](https://github.com/OpenAssetIO/OpenAssetIO/issues/1088)
+
 v1.0.0-alpha.14
 ---------------
 
diff --git a/cmake/Config.cmake.in b/cmake/packaging/Config.cmake.in
similarity index 100%
rename from cmake/Config.cmake.in
rename to cmake/packaging/Config.cmake.in
diff --git a/cmake/packaging/README.md b/cmake/packaging/README.md
new file mode 100644
index 000000000..46b75aac8
--- /dev/null
+++ b/cmake/packaging/README.md
@@ -0,0 +1,22 @@
+# Metadata files to be bundled with the installed package
+
+This directory contains files used for package discovery in the install
+tree.
+
+This includes the CMake config files, as well as a Python "dist-info"
+bundle.
+
+The CMake config files are used to allow CMake's `find_package` to
+discover an installed OpenAssetIO CMake package (see [CMake docs](https://cmake.org/cmake/help/latest/manual/cmake-packages.7.html)
+for more info).
+
+The Python dist-info allows Python package managers (such as pip) to
+detect the presence of an `openassetio` package, and is configured such
+that a well-behaved package manager will error when trying to overwrite
+it. Specifically, the dist-info deliberately excludes a `RECORD` file
+(see [Python docs](https://packaging.python.org/en/latest/specifications/recording-installed-packages/#intentionally-preventing-changes-to-installed-packages)
+for more info).
+
+Some of the config files are templates (as evidenced by the `.in`
+suffix), and will be rendered to their final form as part of a CMake
+build/install.
diff --git a/cmake/Variables.cmake.in b/cmake/packaging/Variables.cmake.in
similarity index 100%
rename from cmake/Variables.cmake.in
rename to cmake/packaging/Variables.cmake.in
diff --git a/cmake/packaging/python.dist-info/INSTALLER b/cmake/packaging/python.dist-info/INSTALLER
new file mode 100644
index 000000000..a3ea3e438
--- /dev/null
+++ b/cmake/packaging/python.dist-info/INSTALLER
@@ -0,0 +1,1 @@
+cmake
diff --git a/cmake/packaging/python.dist-info/METADATA.in b/cmake/packaging/python.dist-info/METADATA.in
new file mode 100644
index 000000000..839457b19
--- /dev/null
+++ b/cmake/packaging/python.dist-info/METADATA.in
@@ -0,0 +1,3 @@
+Metadata-Version: 2.1
+Name: openassetio
+Version: @OPENASSETIO_PYTHON_PACKAGE_VERSION@
diff --git a/cmake/packaging/python.dist-info/REQUESTED b/cmake/packaging/python.dist-info/REQUESTED
new file mode 100644
index 000000000..e69de29bb
diff --git a/cmake/packaging/python.dist-info/top_level.txt b/cmake/packaging/python.dist-info/top_level.txt
new file mode 100644
index 000000000..1650c3f8f
--- /dev/null
+++ b/cmake/packaging/python.dist-info/top_level.txt
@@ -0,0 +1,1 @@
+openassetio
diff --git a/doc/BUILDING.md b/doc/BUILDING.md
index 5577ef5d3..987810c1b 100644
--- a/doc/BUILDING.md
+++ b/doc/BUILDING.md
@@ -177,26 +177,27 @@ about [here](SANDBOXED_BUILDS.md).
 OpenAssetIO provides several custom CMake options to control the
 behaviour of the build.
 
-| Option      | Description | Default |
-| ----------- | ----------- | ------- |
-| `BUILD_SHARED_LIBS` | `ON` to build shared libraries. `OFF` to build static libraries. | `ON` |
-| `OPENASSETIO_ENABLE_PYTHON` | Additionally build python bindings | `ON` |
-| `OPENASSETIO_ENABLE_C` | Additionally build C bindings | `OFF` |
-| `OPENASSETIO_ENABLE_TESTS` | Additionally build tests | `OFF` |
-| `OPENASSETIO_ENABLE_PYTHON_TEST_VENV` | Automatically create environment when running tests | `ON` |
-| `OPENASSETIO_WARNINGS_AS_ERRORS` | Treat compiler warnings as errors | `OFF` |
-| `OPENASSETIO_ENABLE_IPO` | Enable Interprocedural Optimization, aka Link Time Optimization (LTO) | `ON` |
-| `OPENASSETIO_ENABLE_POSITION_INDEPENDENT_CODE` | Enable position independent code for static library builds | `ON` |
-| `OPENASSETIO_GLIBCXX_USE_CXX11_ABI` | When building under gcc, use the new C++11 library ABI | `OFF` |
-| `OPENASSETIO_ENABLE_NEW_DTAGS` | Set RUNPATH, overriding RPATH, on linux platforms | `OFF` |
-| `OPENASSETIO_ENABLE_COVERAGE` | Enable coverage reporting for gcc/clang | `OFF` |
-| `OPENASSETIO_ENABLE_SANITIZER_ADDRESS` | Enable address sanitizer | `OFF` |
-| `OPENASSETIO_ENABLE_SANITIZER_LEAK` | Enable leak sanitizer | `OFF` |
-| `OPENASSETIO_ENABLE_SANITIZER_UNDEFINED_BEHAVIOR`   | Enable undefined behavior sanitizer | `OFF` |
-| `OPENASSETIO_ENABLE_SANITIZER_THREAD` | Enable thread sanitizer | `OFF` |
-| `OPENASSETIO_ENABLE_SANITIZER_MEMORY` | Enable memory sanitizer | `OFF` |
-| `OPENASSETIO_ENABLE_CLANG_TIDY` | Enable clang-tidy analysis during build | `OFF` |
-| `OPENASSETIO_ENABLE_CPPLINT` |Enable cpplint linter during build | `OFF` |
+| Option                                            | Description                                                           | Default |
+|---------------------------------------------------|-----------------------------------------------------------------------|---------|
+| `BUILD_SHARED_LIBS`                               | `ON` to build shared libraries. `OFF` to build static libraries.      | `ON`    |
+| `OPENASSETIO_ENABLE_PYTHON`                       | Additionally build python bindings                                    | `ON`    |
+| `OPENASSETIO_ENABLE_PYTHON_INSTALL_DIST_INFO`     | Create a dist-info metadata directory alongside Python installation   | `ON`    |
+| `OPENASSETIO_ENABLE_C`                            | Additionally build C bindings                                         | `OFF`   |
+| `OPENASSETIO_ENABLE_TESTS`                        | Additionally build tests                                              | `OFF`   |
+| `OPENASSETIO_ENABLE_PYTHON_TEST_VENV`             | Automatically create environment when running tests                   | `ON`    |
+| `OPENASSETIO_WARNINGS_AS_ERRORS`                  | Treat compiler warnings as errors                                     | `OFF`   |
+| `OPENASSETIO_ENABLE_IPO`                          | Enable Interprocedural Optimization, aka Link Time Optimization (LTO) | `ON`    |
+| `OPENASSETIO_ENABLE_POSITION_INDEPENDENT_CODE`    | Enable position independent code for static library builds            | `ON`    |
+| `OPENASSETIO_GLIBCXX_USE_CXX11_ABI`               | When building under gcc, use the new C++11 library ABI                | `OFF`   |
+| `OPENASSETIO_ENABLE_NEW_DTAGS`                    | Set RUNPATH, overriding RPATH, on linux platforms                     | `OFF`   |
+| `OPENASSETIO_ENABLE_COVERAGE`                     | Enable coverage reporting for gcc/clang                               | `OFF`   |
+| `OPENASSETIO_ENABLE_SANITIZER_ADDRESS`            | Enable address sanitizer                                              | `OFF`   |
+| `OPENASSETIO_ENABLE_SANITIZER_LEAK`               | Enable leak sanitizer                                                 | `OFF`   |
+| `OPENASSETIO_ENABLE_SANITIZER_UNDEFINED_BEHAVIOR` | Enable undefined behavior sanitizer                                   | `OFF`   |
+| `OPENASSETIO_ENABLE_SANITIZER_THREAD`             | Enable thread sanitizer                                               | `OFF`   |
+| `OPENASSETIO_ENABLE_SANITIZER_MEMORY`             | Enable memory sanitizer                                               | `OFF`   |
+| `OPENASSETIO_ENABLE_CLANG_TIDY`                   | Enable clang-tidy analysis during build                               | `OFF`   |
+| `OPENASSETIO_ENABLE_CPPLINT`                      | Enable cpplint linter during build                                    | `OFF`   |
 
 ### Presets
 
diff --git a/src/openassetio-python/CMakeLists.txt b/src/openassetio-python/CMakeLists.txt
index 5ff93e0a1..186a5440b 100644
--- a/src/openassetio-python/CMakeLists.txt
+++ b/src/openassetio-python/CMakeLists.txt
@@ -6,7 +6,7 @@ add_subdirectory(bridge)
 # Python C extension module making C++ types usable in Python.
 add_subdirectory(cmodule)
 
-# Install OpenAssetIO pure Python into the venv.
+# Install OpenAssetIO pure Python into the Python environment.
 install(
     DIRECTORY package/openassetio
     DESTINATION "${OPENASSETIO_PYTHON_SITEDIR}"
@@ -14,6 +14,39 @@ install(
 )
 
 
+#-----------------------------------------------------------------------
+# Install dist-info into the Python environment, to prevent accidental
+# overwrite, e.g. pip.
+
+if (OPENASSETIO_ENABLE_PYTHON_INSTALL_DIST_INFO)
+
+    file(READ pyproject.toml _pyproject_toml)
+    string(REGEX MATCH [[version *= *"([^"]+)"]] _unused "${_pyproject_toml}")
+    set(OPENASSETIO_PYTHON_PACKAGE_VERSION ${CMAKE_MATCH_1})
+    if (NOT OPENASSETIO_PYTHON_PACKAGE_VERSION)
+        message(FATAL_ERROR "Failed to parse version from pyproject.toml")
+    endif ()
+
+    set(_dist_info_dir_name openassetio-${OPENASSETIO_PYTHON_PACKAGE_VERSION}.dist-info)
+
+    file(
+        COPY
+        "${PROJECT_SOURCE_DIR}/cmake/packaging/python.dist-info/INSTALLER"
+        "${PROJECT_SOURCE_DIR}/cmake/packaging/python.dist-info/REQUESTED"
+        "${PROJECT_SOURCE_DIR}/cmake/packaging/python.dist-info/top_level.txt"
+        DESTINATION "${_dist_info_dir_name}"
+    )
+    configure_file(
+        "${PROJECT_SOURCE_DIR}/cmake/packaging/python.dist-info/METADATA.in"
+        "${_dist_info_dir_name}/METADATA"
+    )
+    install(
+        DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/${_dist_info_dir_name}"
+        DESTINATION "${OPENASSETIO_PYTHON_SITEDIR}"
+    )
+
+endif ()
+
 #-----------------------------------------------------------------------
 # Tests
 


Test patch:
diff --git a/src/openassetio-python/tests/CMakeLists.txt b/src/openassetio-python/tests/CMakeLists.txt
index 6806212d6..c79679fed 100644
--- a/src/openassetio-python/tests/CMakeLists.txt
+++ b/src/openassetio-python/tests/CMakeLists.txt
@@ -19,6 +19,31 @@ add_subdirectory(cmodule)
 add_subdirectory(package)
 
 
+#-----------------------------------------------------------------------
+# CMake Python packaging tests.
+
+if (OPENASSETIO_ENABLE_PYTHON_INSTALL_DIST_INFO)
+    openassetio_add_pytest_target(
+        openassetio.internal.pytest.cmake_package
+        "Running pytest check for CMake dist-info packaging"
+        "${CMAKE_CURRENT_LIST_DIR}/test_cmake.py"
+        "${PROJECT_SOURCE_DIR}"
+        "${CMAKE_INSTALL_PREFIX}/${OPENASSETIO_PYTHON_SITEDIR}"
+    )
+    openassetio_add_test_target(openassetio.internal.pytest.cmake_package)
+    openassetio_add_test_fixture_dependencies(
+        openassetio.internal.pytest.cmake_package
+        openassetio.internal.install
+    )
+    openassetio_add_test_venv_fixture_dependency(openassetio.internal.pytest.cmake_package)
+    set_tests_properties(
+        openassetio.internal.pytest.cmake_package
+        PROPERTIES
+        ENVIRONMENT OPENASSETIO_CMAKE_PACKAGE_VERSION=${PROJECT_VERSION}
+    )
+endif ()
+
+
 #-----------------------------------------------------------------------
 # Test dependencies.
 
diff --git a/src/openassetio-python/tests/test_cmake.py b/src/openassetio-python/tests/test_cmake.py
new file mode 100644
index 000000000..62b6b9de4
--- /dev/null
+++ b/src/openassetio-python/tests/test_cmake.py
@@ -0,0 +1,59 @@
+#
+#   Copyright 2013-2023 The Foundry Visionmongers Ltd
+#
+#   Licensed 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.
+#
+"""
+Test CMake installed package
+"""
+# pylint: disable=missing-function-docstring
+import os
+
+import pytest
+
+
+try:
+    from importlib import metadata
+except:
+    import importlib_metadata as metadata
+
+
+@pytest.mark.skipif(
+    os.environ.get("OPENASSETIO_CMAKE_PACKAGE_VERSION") is None, reason="CMake package only"
+)
+def test_cmake_dist_info():
+    dist = metadata.distribution("openassetio")
+
+    # Check METADATA file exists with required keys.
+    assert set(dist.metadata.keys()) == {"Name", "Metadata-Version", "Version"}
+    assert dist.metadata["Name"] == "openassetio"
+    assert dist.metadata["Version"].startswith(os.environ["OPENASSETIO_CMAKE_PACKAGE_VERSION"])
+
+    # The lack of a RECORD means that `pip` is unable to accidentally
+    # uninstall the package
+    assert dist.read_text("RECORD") is None
+
+    # The INSTALLER is used by `pip` to provide a hint when reporting
+    # that it is unable to install a package due to no RECORD.
+    installer = dist.read_text("INSTALLER")
+    assert installer is not None
+    assert installer.strip() == "cmake"
+
+    # The above uses files in the dist-info directory, whereas pip uses
+    # the directory name itself. So check that they match.
+    dist_info_path = os.path.join(
+        dist.locate_file(""), f"{dist.metadata['Name']}-{dist.metadata['Version']}.dist-info"
+    )
+    assert os.path.isdir(dist_info_path)
+    dist_from_dir = metadata.Distribution.at(dist_info_path)
+    assert dict(dist.metadata) == dict(dist_from_dir.metadata)