Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
Python exception translation
## What

Ensure information is not lost from Python exceptions when bridged to C++

Also see #866 (duplicate issue)

## Why

We currently lose information about Python exceptions when caught in C++.

When Python code calls a C++ function, and that C++ function raises an exception, there is a built-in hook via pybind11 to translate the C++ exception to a Python exception (see usages of `register_exception_translator`).

However, when C++ calls a Python function, and that Python function raises an exception, pybind11 will detect the Python exception and throw an (unhelpfully named) `error_already_set` exception. There is no pybind11 hook available to modify this. 

In this case, consumers (hosts or manager plugins) must catch `std::exception` and interrogate the `what()` string to disambiguate.

We really need to devise a way to translate Python exceptions to the appropriate C++ exceptions, so that consumers can disambiguate using the exception type (i.e. in `catch` blocks).

An unfortunate wrinkle is in the special status that pybind11's `error_already_set` exception has within pybind11 itself. If this exception propagates _back_ into Python, then the original Python exception is restored, complete with stack trace etc.

So ideally we would only conditionally translate an `error_already_set` exception into a more appropriate C++ exception, by somehow figuring out whether the exception will be caught in C++ or Python code.


## ACs 

- Define approach for the above
Python exception translation
## What

Ensure information is not lost from Python exceptions when bridged to C++

Also see #866 (duplicate issue)

## Why

We currently lose information about Python exceptions when caught in C++.

When Python code calls a C++ function, and that C++ function raises an exception, there is a built-in hook via pybind11 to translate the C++ exception to a Python exception (see usages of `register_exception_translator`).

However, when C++ calls a Python function, and that Python function raises an exception, pybind11 will detect the Python exception and throw an (unhelpfully named) `error_already_set` exception. There is no pybind11 hook available to modify this. 

In this case, consumers (hosts or manager plugins) must catch `std::exception` and interrogate the `what()` string to disambiguate.

We really need to devise a way to translate Python exceptions to the appropriate C++ exceptions, so that consumers can disambiguate using the exception type (i.e. in `catch` blocks).

An unfortunate wrinkle is in the special status that pybind11's `error_already_set` exception has within pybind11 itself. If this exception propagates _back_ into Python, then the original Python exception is restored, complete with stack trace etc.

So ideally we would only conditionally translate an `error_already_set` exception into a more appropriate C++ exception, by somehow figuring out whether the exception will be caught in C++ or Python code.


## ACs 

- Define approach for the above
Ensure use of inclusive terminology
## What

Ensure our codebase(s) is in line with our policy on inclusive terminology.

## Notes

This was spawned from https://github.com/OpenAssetIO/OpenAssetIO/pull/1110#issuecomment-1727471617

Requires guidance on policy.  E.g. a problematic word list.  'black', 'white', 'master' and 'slave' have already been checked...



Discussion / hints:
I'd imagine any pybind11 trampoline class (i.e. anywhere we have a Python class that can inherit from C++) needs to have its member functions wrapped in `try`/`catch`, plus any other functions where we call out to Python (e.g. `createPythonPluginSystemManagerImplementationFactory`)

I wonder if multiple inheritance could help us here.

```c++
class openassetio::PluginError : std::runtime_error { ... };

class openassetio::PyPluginError : openassetio::PluginError, py::error_already_set { ... };

void rethrowCppException(std::exception_ptr pexc) {
  try {
    std::rethrow_exception(std::move(pexc))
  } catch (py::error_already_set& exc) {
    // ... disambiguate ... then:
   throw PyPluginError{exc};  // Absorb `error_already_set` and construct `PluginError`.
  }
}

void initialize(InfoDictionary managerSettings, const HostSessionPtr& hostSession) override {
  try {
    PYBIND11_OVERRIDE_PURE(void, ManagerInterface, initialize, std::move(managerSettings),
                           hostSession);
  } catch (...) {
    rethrowCppException(std::current_exception());
  }
}
```
Then C++ consumers can catch `PluginError`, and Python consumers will get the original Python exception.  This might also work with catching in C++ then rethrowing back to Python.
Dont know if its relevant, but some prior art here too https://github.com/ImageEngine/cortex/blob/main/include/IECorePython/ExceptionAlgo.h
This issue duplicates #866.  Closed that other issue in favour of this one.
Looking specifically at the project linked above, ImageEngine's Cortex:

(Boost.Python) `error_already_set` catches are found around most of the Boost equivalent of pybind11's trampoline methods, translated to either a generic exception or using an `exception_ptr` stashed on the Python exception object.
Quick [Compiler Explorer mockup](https://godbolt.org/#z:OYLghAFBqd5QCxAYwPYBMCmBRdBLAF1QCcAaPECAMzwBtMA7AQwFtMQByARg9KtQYEAysib0QXACx8BBAKoBnTAAUAHpwAMvAFYTStJg1DIApACYAQuYukl9ZATwDKjdAGFUtAK4sGIAJxmpK4AMngMmAByPgBGmMQgZtIADqgKhE4MHt6%2BAUGp6Y4CYRHRLHEJSbaY9kUMQgRMxATZPn6B1bWZDU0EJVGx8YnSCo3Nrbkdo739ZRXDAJS2qF7EyOwc5gDM4cjeWADUJltuTqPEmKzH2CYaAILbu/uYRyejWKrryQTXtw/35y8DgO8WIJAA%2BmILkx0ABPcFKAgHEAHd4gEDELyCPBscGgkhHADsVnuBzJBy86SMqII6HRmOxuPxCQxWMcTOIYOIxxJD0JABEeX8/oDgW5kslsJ9MN9MsiaXTWYzMHjOQSTMS/uSKVTgAr6WycSrmQblaquUL7hrBVteSKCJjgcpYeLJdLZQJ5a6pV86qQQWriJDaNC4QjMEiNbztc7ve66hBmcHQ/DEQdkrCfQstdrySik1DLmHEVG0SAWKgAG6YCAZrPW/1x32ZKN1z4AOgA7ggmAQINmBUSrALhSPbcL7uEkSwmOF%2B0Oc2SHbCF6Tc0viCuo4v1%2BTl6u7ruj/qUCtIydjm4jmYzAAVBBgzvhPWYeNyqhglgHZ0EBACEwAKxuAw5hmJah7Huuv6PgGXLJkWqYRtuN4KKgbCwSQoHWuBkFktaByiAQyAIAciaBvBMKIUir7IAsB64bmZZoF455uJe15mD%2Bf4MCCb6eqIXjAAgSKGOgBwOoYCgGAQmBiUQBwILCMTEHgYk0TKdQoqBrxsScvHIF2PZ9nRl7sVhQEgTeOEMXuD6oJ234uhKPoaZkEBlhW1aJp8CzZuOa6QdhAW5vhhHEaRaAMKMBxNq5/5mAAbPpJmasFR5MWeOlmTebGWNY%2BlxTxAlCSJDBiRc0H2X4HFZXpNGGb286mXp5nAVh/kQZBlWdtZ65BZ1RL8gRvbhRAkXRQWIYIeGPyJcl9G7hlLG1VeoFcZ66kekVTCCcJBwpuJqDpmCyRMMAvYvDETDIAA1gcU5Het1Xac1V71d2jUmReLVWRZ7XRuS/XWhwSy0JwAG8H4HBaKQqCcLlViWKiKxrC82w8KQBCaCDSw3SAAEJe2CVmAAHISGhcAB/iSABWxbBoWz6JwkiQ9jsOcLwCggBomPY0scCwDAiCniwyR0PE5CUGgoviwkwDU6QWCVng6wAGp4JgnYAPLJIwnAYzQtAycQXMQDEbMxOETSwvrvCW8wm5azE2iYA4tukNLbCCFrDC0Db0O8FgMSCW4Yi0Fz3CB5gM5GOIAeK3gFwOHg1YRzDr6uyxGww1ONRs7QeDKdbHhYGzDo4nzfAGMACjq5rOt65HMiCCIYjsFIzfyEoahs7oXD6IYxjWNY%2BiF1zkBLKgW0RwAtFrCgHDP7zHPyph5ZYXCErwVagqpmDj/2nSu5piZlRMfj96E4QDOUQz9wUGQCOfegP3UsyDAk/d2Mf3RjC0nhtD0N/ZOAgejNHfrfT%2Btg/7Py/n/CB8wuBLFQqsdYEhQbg1ZvHOGHADiqBJglGeCVJAHGAMgZABx/DthIRAXAhB1RmC2Eg3gWMA6%2BVIAgIsQxD540kGYImUhJCSApoSQkXB/AaH8B3MGHAWakBYPjHmUMYY4M5tzXmbDSAC2FmeZILFJaJnwEQFSdJ%2B78BbqIcQHdzFdxUOoeOfdSCdmIEwZItsMEcAhqQZR29OBaxYnopEqAqB4IIUQkhZCKFUJoR4GW9BiDXiYQsFhfMlicJhNwjxciFEASUWzVRth1GsK0Ow3hWx2xbDEWYfwJNhEAUkAlBK/hCRMw4FsLBKiOYaJKR4swHTfEcBSWwpY1YTaZBAJIIAA) of the sketch I did above, seems to be viable.
I'd imagine any pybind11 trampoline class (i.e. anywhere we have a Python class that can inherit from C++) needs to have its member functions wrapped in `try`/`catch`, plus any other functions where we call out to Python (e.g. `createPythonPluginSystemManagerImplementationFactory`)

I wonder if multiple inheritance could help us here.

```c++
class openassetio::PluginError : std::runtime_error { ... };

class openassetio::PyPluginError : openassetio::PluginError, py::error_already_set { ... };

void rethrowCppException(std::exception_ptr pexc) {
  try {
    std::rethrow_exception(std::move(pexc))
  } catch (py::error_already_set& exc) {
    // ... disambiguate ... then:
   throw PyPluginError{exc};  // Absorb `error_already_set` and construct `PluginError`.
  }
}

void initialize(InfoDictionary managerSettings, const HostSessionPtr& hostSession) override {
  try {
    PYBIND11_OVERRIDE_PURE(void, ManagerInterface, initialize, std::move(managerSettings),
                           hostSession);
  } catch (...) {
    rethrowCppException(std::current_exception());
  }
}
```
Then C++ consumers can catch `PluginError`, and Python consumers will get the original Python exception.  This might also work with catching in C++ then rethrowing back to Python.
Dont know if its relevant, but some prior art here too https://github.com/ImageEngine/cortex/blob/main/include/IECorePython/ExceptionAlgo.h
This issue duplicates #866.  Closed that other issue in favour of this one.
Looking specifically at the project linked above, ImageEngine's Cortex:

(Boost.Python) `error_already_set` catches are found around most of the Boost equivalent of pybind11's trampoline methods, translated to either a generic exception or using an `exception_ptr` stashed on the Python exception object.
Quick [Compiler Explorer mockup](https://godbolt.org/#z:OYLghAFBqd5QCxAYwPYBMCmBRdBLAF1QCcAaPECAMzwBtMA7AQwFtMQByARg9KtQYEAysib0QXACx8BBAKoBnTAAUAHpwAMvAFYTStJg1DIApACYAQuYukl9ZATwDKjdAGFUtAK4sGIAJxmpK4AMngMmAByPgBGmMQgZtIADqgKhE4MHt6%2BAUGp6Y4CYRHRLHEJSbaY9kUMQgRMxATZPn6B1bWZDU0EJVGx8YnSCo3Nrbkdo739ZRXDAJS2qF7EyOwc5gDM4cjeWADUJltuTqPEmKzH2CYaAILbu/uYRyejWKrryQTXtw/35y8DgO8WIJAA%2BmILkx0ABPcFKAgHEAHd4gEDELyCPBscGgkhHADsVnuBzJBy86SMqII6HRmOxuPxCQxWMcTOIYOIxxJD0JABEeX8/oDgW5kslsJ9MN9MsiaXTWYzMHjOQSTMS/uSKVTgAr6WycSrmQblaquUL7hrBVteSKCJjgcpYeLJdLZQJ5a6pV86qQQWriJDaNC4QjMEiNbztc7ve66hBmcHQ/DEQdkrCfQstdrySik1DLmHEVG0SAWKgAG6YCAZrPW/1x32ZKN1z4AOgA7ggmAQINmBUSrALhSPbcL7uEkSwmOF%2B0Oc2SHbCF6Tc0viCuo4v1%2BTl6u7ruj/qUCtIydjm4jmYzAAVBBgzvhPWYeNyqhglgHZ0EBACEwAKxuAw5hmJah7Huuv6PgGXLJkWqYRtuN4KKgbCwSQoHWuBkFktaByiAQyAIAciaBvBMKIUir7IAsB64bmZZoF455uJe15mD%2Bf4MCCb6eqIXjAAgSKGOgBwOoYCgGAQmBiUQBwILCMTEHgYk0TKdQoqBrxsScvHIF2PZ9nRl7sVhQEgTeOEMXuD6oJ234uhKPoaZkEBlhW1aJp8CzZuOa6QdhAW5vhhHEaRaAMKMBxNq5/5mAAbPpJmasFR5MWeOlmTebGWNY%2BlxTxAlCSJDBiRc0H2X4HFZXpNGGb286mXp5nAVh/kQZBlWdtZ65BZ1RL8gRvbhRAkXRQWIYIeGPyJcl9G7hlLG1VeoFcZ66kekVTCCcJBwpuJqDpmCyRMMAvYvDETDIAA1gcU5Het1Xac1V71d2jUmReLVWRZ7XRuS/XWhwSy0JwAG8H4HBaKQqCcLlViWKiKxrC82w8KQBCaCDSw3SAAEJe2CVmAAHISGhcAB/iSABWxbBoWz6JwkiQ9jsOcLwCggBomPY0scCwDAiCniwyR0PE5CUGgoviwkwDU6QWCVng6wAGp4JgnYAPLJIwnAYzQtAycQXMQDEbMxOETSwvrvCW8wm5azE2iYA4tukNLbCCFrDC0Db0O8FgMSCW4Yi0Fz3CB5gM5GOIAeK3gFwOHg1YRzDr6uyxGww1ONRs7QeDKdbHhYGzDo4nzfAGMACjq5rOt65HMiCCIYjsFIzfyEoahs7oXD6IYxjWNY%2BiF1zkBLKgW0RwAtFrCgHDP7zHPyph5ZYXCErwVagqpmDj/2nSu5piZlRMfj96E4QDOUQz9wUGQCOfegP3UsyDAk/d2Mf3RjC0nhtD0N/ZOAgejNHfrfT%2Btg/7Py/n/CB8wuBLFQqsdYEhQbg1ZvHOGHADiqBJglGeCVJAHGAMgZABx/DthIRAXAhB1RmC2Eg3gWMA6%2BVIAgIsQxD540kGYImUhJCSApoSQkXB/AaH8B3MGHAWakBYPjHmUMYY4M5tzXmbDSAC2FmeZILFJaJnwEQFSdJ%2B78BbqIcQHdzFdxUOoeOfdSCdmIEwZItsMEcAhqQZR29OBaxYnopEqAqB4IIUQkhZCKFUJoR4GW9BiDXiYQsFhfMlicJhNwjxciFEASUWzVRth1GsK0Ow3hWx2xbDEWYfwJNhEAUkAlBK/hCRMw4FsLBKiOYaJKR4swHTfEcBSWwpY1YTaZBAJIIAA) of the sketch I did above, seems to be viable.


Fix patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 02691f487..a08828f62 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -110,19 +110,26 @@ jobs:
         name: doxygen-documentation
         path: doc/doxygen/html
 
-  business-language:
+  disallowed-phrases:
     runs-on: ubuntu-latest
+    name: Disallowed phrases
 
     steps:
     - name: Checkout source
       uses: actions/checkout@v4
 
-    - name: Check business language
-      run: |
-        # Check that the acronym version of OpenAssetIO isn't in use
-        # in the code base, see:
-        #   https://github.com/OpenAssetIO/OpenAssetIO/issues/153
-        # NB: The quotes deliberately avoid the string in question
-        # appearing in this file, and failing the test.
+      # Check that the acronym version of OpenAssetIO isn't in use in
+      # the code base, see:
+      #   https://github.com/OpenAssetIO/OpenAssetIO/issues/153
+      # NB: The quotes deliberately avoid the string in question
+      # appearing in this file, and failing the test.
+    - name: Ambiguous acronym
+      run: >
         ! grep -ir "o""aio" .
 
+      # Check that we always use the `OPENASSETIO_`-prefixed pybind11
+      # override macros. Uses Perl regexp with negative lookbehind.
+    - name: pybind11 macros
+      run: >
+        ! grep -Pr '(?<!OPENASSETIO_)PYBIND11_OVERRIDE'
+        --exclude overrideMacros.hpp --exclude-dir _openassetio_test src
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 0892bd514..c6dec46d3 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -4,6 +4,13 @@ Release Notes
 v1.0.0-beta.x.x
 ---------------
 
+### New Features
+
+- Propagate `OpenAssetIOException`-derived Python exceptions as a
+  corresponding C++ exception if a Python method implementation raises
+  when transparently called from C++.
+  [#947](https://github.com/OpenAssetIO/OpenAssetIO/issues/947)
+
 ### Improvements
 
 - Added the string representation of an `EntityReference` when `repr`'d
diff --git a/doc/contributing/CODING_STANDARDS.md b/doc/contributing/CODING_STANDARDS.md
index 084a9357b..4b6a3abf8 100644
--- a/doc/contributing/CODING_STANDARDS.md
+++ b/doc/contributing/CODING_STANDARDS.md
@@ -232,6 +232,25 @@ a null pointer.
     .def("setHost", py::arg("host").none(false))
 ```
 
+### Polymorphic override macros
+
+In order to support C++ class instances transparently calling into a
+mirrored Python instance, pybind11 has support for so-called
+["trampoline" classes](https://pybind11.readthedocs.io/en/stable/advanced/classes.html#overriding-virtual-functions-in-python),
+which dispatch either to the Python method override, or to the C++ base
+class implementation if no Python override is found. Method bodies
+use convenience macros to implement this logic.
+
+In pybind11, any exception that occurs in Python, and propagates to C++,
+is translated to an `error_already_set` C++ exception. In order to
+support Python->C++ exception type translation, we must augment the
+pybind11 macros.
+
+So instead of using the `PYBIND11_OVERRIDE_*` family of macros, we must
+use `OPENASSETIO_PYBIND11_OVERRIDE_*`, which decorates the pybind11
+implementation with additional functionality, such as exception
+translation.
+
 ### Python object lifetime
 
 Due to a [Pybind issue](https://github.com/pybind/pybind11/issues/1333),
@@ -268,7 +287,7 @@ member functions from, for example
 
 ```c++
 std::shared_ptr<MyReturnType> PyMyClass::myMethod(myArg) override {
-  PYBIND11_OVERRIDE(
+  OPENASSETIO_PYBIND11_OVERRIDE(
       std::shared_ptr<MyReturnType>, MyClass, myMethod, myArg);
 }
 ```
@@ -277,7 +296,7 @@ to
 
 ```c++
 std::shared_ptr<MyReturnType> PyMyClass::myMethod(myArg) override {
-  PYBIND11_OVERRIDE(
+  OPENASSETIO_PYBIND11_OVERRIDE(
       PyRetainingSharedPtr<MyReturnType>, MyClass, myMethod, myArg);
 }
 ```
@@ -423,14 +442,6 @@ provided externally (e.g. manager plugins).
 Any methods not released should be in O(1) time and guaranteed to not
 cause a deadlock by calling out to python off-thread.
 
-Until https://github.com/pybind/pybind11/issues/4878 is fixed, pure
-virtual bindings should use the `OPENASSETIO_PYBIND11_OVERRIDE_PURE`
-macro in the pybind11 [trampoline
-class](https://pybind11.readthedocs.io/en/stable/advanced/classes.html),
-rather than `PYBIND11_OVERRIDE_PURE`. See the issue description for more
-details.
-
-
 ## Environment variables
 
 All environment variables should be prefixed with `OPENASSETIO_`.
diff --git a/src/openassetio-core/include/openassetio/errors/exceptions.hpp b/src/openassetio-core/include/openassetio/errors/exceptions.hpp
index d5cee4581..92871f956 100644
--- a/src/openassetio-core/include/openassetio/errors/exceptions.hpp
+++ b/src/openassetio-core/include/openassetio/errors/exceptions.hpp
@@ -4,6 +4,7 @@
 
 #include <stdexcept>
 #include <string>
+#include <tuple>
 #include <utility>
 
 #include <openassetio/export.h>
@@ -107,6 +108,12 @@ struct OPENASSETIO_CORE_EXPORT BatchElementException : OpenAssetIOException {
   BatchElementError error;
 };
 
+/// List of all OpenAssetIO-specific exceptions. Useful for
+/// introspection in language bindings.
+using AllExceptions =
+    std::tuple<OpenAssetIOException, InputValidationException, ConfigurationException,
+               NotImplementedException, UnhandledException, BatchElementException>;
+
 /**
  * @}
  */
diff --git a/src/openassetio-python/cmodule/CMakeLists.txt b/src/openassetio-python/cmodule/CMakeLists.txt
index f5c09d20d..ffa7a6066 100644
--- a/src/openassetio-python/cmodule/CMakeLists.txt
+++ b/src/openassetio-python/cmodule/CMakeLists.txt
@@ -45,6 +45,7 @@ target_sources(
     src/constantsBinding.cpp
     src/ContextBinding.cpp
     src/EntityReferenceBinding.cpp
+    src/errors/exceptionsAsserts.cpp
     src/errors/exceptionsBinding.cpp
     src/errors/BatchElementErrorBinding.cpp
     src/hostApi/EntityReferencePagerBinding.cpp
diff --git a/src/openassetio-python/cmodule/src/_openassetio.hpp b/src/openassetio-python/cmodule/src/_openassetio.hpp
index 29448e114..e4bf563ca 100644
--- a/src/openassetio-python/cmodule/src/_openassetio.hpp
+++ b/src/openassetio-python/cmodule/src/_openassetio.hpp
@@ -20,54 +20,6 @@ OPENASSETIO_FWD_DECLARE(log, LoggerInterface)
 OPENASSETIO_FWD_DECLARE(hostApi, HostInterface)
 OPENASSETIO_FWD_DECLARE(hostApi, ManagerImplementationFactoryInterface)
 
-/**
- * Similar to PYBIND11_OVERRIDE, but allows different arguments to be
- * given to the Python implementation and the C++ fallback (base
- * class) implementation.
- *
- * For example, this is useful to decorate std::function callbacks
- * with `PyRetainingSharedPtr`s, so that Python objects are kept alive
- * as they pass through C++ callbacks.
- *
- * The CppArgs parameter must be a parentheses-enclosed,
- * comma-separated, list of arguments. These are given to the C++ base
- * class implementation, if no Python override is found.
- *
- * All remaining arguments following the CppArgs (not
- * parentheses-enclosed) are passed to the Python override
- * implementation, if one exists.
- */
-#define OPENASSETIO_PYBIND11_OVERRIDE_ARGS(Ret, Class, Fn, CppArgs, ... /* PyArgs */) \
-  PYBIND11_OVERRIDE_IMPL(PYBIND11_TYPE(Ret), PYBIND11_TYPE(Class), #Fn, __VA_ARGS__); \
-  return Class::Fn CppArgs
-
-/**
- * Work around https://github.com/pybind/pybind11/issues/4878
- *
- * In a Debug build, where `assert` is enabled, pybind11 will do an
- * `assert(!PyErr_Occurred())` before throwing "Tried to call pure
- * virtual function". Since Python 3.9, the PyErr_Occurred() function
- * requires the GIL to be acquired.
- *
- * The following macro duplicates PYBIND11_OVERRIDE_PURE_NAME and
- * inserts a `gil_scoped_acquire`, in order to work around the problem
- * until it is fixed upstream.
- *
- * @todo Revert to using PYBIND11_OVERRIDE_PURE once upstream fix is
- * available.
- */
-#define OPENASSETIO_PYBIND11_OVERRIDE_PURE_NAME(ret_type, cname, name, fn, ...)               \
-  do {                                                                                        \
-    PYBIND11_OVERRIDE_IMPL(PYBIND11_TYPE(ret_type), PYBIND11_TYPE(cname), name, __VA_ARGS__); \
-    const pybind11::gil_scoped_acquire gil{};                                                 \
-    pybind11::pybind11_fail(                                                                  \
-        "Tried to call pure virtual function \"" PYBIND11_STRINGIFY(cname) "::" name "\"");   \
-  } while (false)
-
-#define OPENASSETIO_PYBIND11_OVERRIDE_PURE(ret_type, cname, fn, ...)                              \
-  OPENASSETIO_PYBIND11_OVERRIDE_PURE_NAME(PYBIND11_TYPE(ret_type), PYBIND11_TYPE(cname), #fn, fn, \
-                                          __VA_ARGS__)
-
 /**
  * Declare a `RetainPyArgs` alias with common template arguments.
  *
diff --git a/src/openassetio-python/cmodule/src/errors/exceptionsAsserts.cpp b/src/openassetio-python/cmodule/src/errors/exceptionsAsserts.cpp
new file mode 100644
index 000000000..dae173875
--- /dev/null
+++ b/src/openassetio-python/cmodule/src/errors/exceptionsAsserts.cpp
@@ -0,0 +1,129 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2013-2023 The Foundry Visionmongers Ltd
+#include <openassetio/errors/exceptions.hpp>
+#include <openassetio/private/python/exceptions.hpp>
+
+namespace {
+using openassetio::python::exceptions::CppExceptionsAndPyClassNames;
+
+/**
+ * @section asserts Compile-time assertions.
+ *
+ * Ensure exception list is in the correct order and is exhaustive.
+ *
+ * @{
+ */
+
+/**
+ * Utility structure to help assert that the list of C++ exception
+ * classes in CppExceptionsAndPyClassNames is in the correct order (i.e.
+ * base classes at the end).
+ *
+ * @tparam I Index of the exception class under test.
+ */
+template <std::size_t I>
+struct AssertClassHierarchyForClassAtIndex {
+  /**
+   * Assert that none of the given classes (looked up by index) are
+   * a base class of the class under test.
+   *
+   * @tparam J Indices of classes to assert are not a base of the class
+   * under test.
+   */
+  template <std::size_t... J>
+  static constexpr void assertNoneOfGivenClassIndicesIsABase(
+      [[maybe_unused]] std::index_sequence<J...> unused) {
+    (assertClassAtIndexIsNotABase<J>(), ...);
+  }
+
+  /**
+   * Assert that a particular class (looked up by index) is not a base
+   * class of the class under test.
+   *
+   * @tparam J Index of class to assert is not a base of the class under
+   * test.
+   */
+  template <std::size_t J>
+  static constexpr void assertClassAtIndexIsNotABase() {
+    static_assert(!std::is_base_of_v<CppExceptionsAndPyClassNames::Exceptions<J>,
+                                     CppExceptionsAndPyClassNames::Exceptions<I>>,
+                  "Base classes must come after subclasses in kCppExceptionsAndPyClassNames");
+  }
+
+  /**
+   * Convenience for a compile-time collection of indices in the list
+   * that come before the class under test.
+   */
+  static constexpr auto kPreviousIndices = std::make_index_sequence<I>{};
+};
+
+/**
+ * Assert that a set of classes (looked up by index) in the
+ * CppExceptionsAndPyClassNames list does not have any base classes
+ * appearing before them in the list.
+ *
+ * @tparam I Indices of classes to assert have no base classes at lower
+ * indices in the list.
+ */
+template <std::size_t... I>
+constexpr bool assertClassesAreInHierarchyOrder(
+    [[maybe_unused]] std::index_sequence<I...> unused) {
+  (AssertClassHierarchyForClassAtIndex<I>::assertNoneOfGivenClassIndicesIsABase(
+       AssertClassHierarchyForClassAtIndex<I>::kPreviousIndices),
+   ...);
+  return true;
+}
+/**
+ * Execute compile-time assertions that no class in the
+ * CppExceptionsAndPyClassNames list has a base class at a lower index
+ * in the list.
+ */
+static_assert(assertClassesAreInHierarchyOrder(CppExceptionsAndPyClassNames::kIndices));
+
+/// List of all OpenAssetIO-specific C++ exceptions.
+using openassetio::errors::AllExceptions;
+
+/**
+ * Assert that an exception in the AllExceptions list (looked up by
+ * index) is found in the given subset of the
+ * CppExceptionsAndPyClassNames list (looked up by indices).
+ *
+ * In practice the subset should be the full set.
+ *
+ * @tparam I Index of exception in AllExceptions.
+ * @tparam J Indices of exceptions in CppExceptionsAndPyClassNames.
+ */
+template <std::size_t I, std::size_t... J>
+constexpr void assertExceptionIsInList([[maybe_unused]] std::index_sequence<J...> unused) {
+  static_assert((std::is_same_v<std::tuple_element_t<I, AllExceptions>,
+                                CppExceptionsAndPyClassNames::Exceptions<J>> ||
+                 ...),
+                "Exception is missing from kCppExceptionsAndPyClassNames");
+}
+
+/**
+ * Assert that a subset of exceptions in the AllExceptions list (looked
+ * up by indices) are all found in the CppExceptionsAndPyClassNames
+ * list.
+ *
+ * In practice the subset should be the full set.
+ *
+ * @tparam I Indices of exceptions in AllExceptions.
+ */
+template <std::size_t... I>
+constexpr bool assertAllExceptionsAreInList([[maybe_unused]] std::index_sequence<I...> unused) {
+  (assertExceptionIsInList<I>(CppExceptionsAndPyClassNames::kIndices), ...);
+  return true;
+}
+/**
+ * Execute compile-time assertions that all exceptions in the
+ * AllExceptions list are also found in the CppExceptionsAndPyClassNames
+ * list.
+ */
+static_assert(
+    assertAllExceptionsAreInList(std::make_index_sequence<std::tuple_size_v<AllExceptions>>{}));
+
+/**
+ * @}
+ */
+}  // namespace
diff --git a/src/openassetio-python/cmodule/src/errors/exceptionsBinding.cpp b/src/openassetio-python/cmodule/src/errors/exceptionsBinding.cpp
index 2b1f95922..75bfc93b3 100644
--- a/src/openassetio-python/cmodule/src/errors/exceptionsBinding.cpp
+++ b/src/openassetio-python/cmodule/src/errors/exceptionsBinding.cpp
@@ -1,5 +1,6 @@
 // SPDX-License-Identifier: Apache-2.0
 // Copyright 2013-2023 The Foundry Visionmongers Ltd
+#include <map>
 #include <string_view>
 
 #include <pybind11/eval.h>
@@ -7,140 +8,242 @@
 #include <pybind11/pybind11.h>
 
 #include <openassetio/errors/exceptions.hpp>
+#include <openassetio/private/python/exceptions.hpp>
 
 #include "../_openassetio.hpp"
 
 namespace {
-void registerNonBatchExceptions(const py::module &mod) {
-  using openassetio::errors::ConfigurationException;
-  using openassetio::errors::InputValidationException;
-  using openassetio::errors::NotImplementedException;
-  using openassetio::errors::OpenAssetIOException;
-  using openassetio::errors::UnhandledException;
-
-  // Register a new exception type. Note that this is not sufficient to
-  // cause C++ exceptions to be translated. See
-  // `register_exception_translator` below.
-  const auto registerPyException = [&mod](const char *pyTypeName, const py::handle &base) {
-    return py::exception<void /* unused */>{mod, pyTypeName, base};
-  };
-
-  // Register our OpenAssetIO exception types. Since these exceptions
-  // simply take a string message and no other data, we can make use
-  // of pybind11's limited custom exception support.
-  const py::object pyOpenAssetIOException =
-      registerPyException("OpenAssetIOException", PyExc_RuntimeError);
-  // NOLINTNEXTLINE(bugprone-throw-keyword-missing)
-  registerPyException("UnhandledException", pyOpenAssetIOException);
-  // NOLINTNEXTLINE(bugprone-throw-keyword-missing)
-  registerPyException("NotImplementedException", pyOpenAssetIOException);
-  const py::object pyInputValidationException =
-      registerPyException("InputValidationException", pyOpenAssetIOException);
-  // NOLINTNEXTLINE(bugprone-throw-keyword-missing)
-  registerPyException("ConfigurationException", pyInputValidationException);
-
-  // Register a function that will translate our C++ exceptions to the
-  // appropriate Python exception type.
-  //
-  // Note that capturing lambdas are not allowed here, so we must
-  // `import` the exception type in the body of the function.
-  py::register_exception_translator([](std::exception_ptr pexc) {
-    if (!pexc) {
-      return;
+using openassetio::errors::BatchElementException;
+using openassetio::errors::ConfigurationException;
+using openassetio::errors::InputValidationException;
+using openassetio::errors::NotImplementedException;
+using openassetio::errors::OpenAssetIOException;
+using openassetio::errors::UnhandledException;
+using openassetio::python::exceptions::CppExceptionsAndPyClassNames;
+
+/**
+ * @section topy Conversion from C++ exception to Python exception.
+ *
+ * @{
+ */
+
+/**
+ * Set the current Python exception in this thread.
+ *
+ * @tparam Exception C++ exception type.
+ * @param exception C++ exception instance.
+ * @param pyModule Python module containing Python exception class.
+ * @param pyClassName Python exception class name to look up in @p
+ * pyModule.
+ */
+template <class Exception>
+void setPyException(const Exception &exception, const py::module_ &pyModule,
+                    // TODO(DF): False positive in clang-tidy 12 :(
+                    // NOLINTNEXTLINE(readability-avoid-const-params-in-decls)
+                    const std::string_view pyClassName) {
+  const py::object pyClass = pyModule.attr(pyClassName.data());
+  const py::object pyInstance = [&] {
+    if constexpr (std::is_same_v<Exception, BatchElementException>) {
+      return pyClass(exception.index, exception.error, exception.what());
+    } else {
+      return pyClass(exception.what());
     }
-    const py::module_ pyModule = py::module_::import("openassetio._openassetio.errors");
-
-    // Use CPython's PyErr_SetObject to set a custom exception type as
-    // the currently active Python exception in this thread.
-    const auto setPyException = [&pyModule](const char *pyTypeName, const auto &exc) {
-      const py::object pyClass = pyModule.attr(pyTypeName);
-      const py::object pyInstance = pyClass(exc.what());
-      PyErr_SetObject(pyClass.ptr(), pyInstance.ptr());
-    };
+  }();
+  PyErr_SetObject(pyClass.ptr(), pyInstance.ptr());
+}
 
-    // Handle the different possible C++ exceptions, creating the
-    // corresponding Python exception and setting it as the active
-    // exception in this thread.
-    try {
+/**
+ * Recursive try-catch.
+ *
+ * Recurse to next nested try-catch block, attempt to catch given
+ * exception type (looked up by index) and, if caught, set the
+ * corresponding Python exception.
+ *
+ * Recursive termination condition occurs at 0th index, whereupon the
+ * given `exception_ptr` is rethrown and propagates up the nested
+ * try-catch blocks until it is caught.
+ *
+ * @tparam I Index of exception type to catch in
+ * CppExceptionsAndPyClassNames list.
+ * @param pyModule Python module containing Python exception classes.
+ * @param pexc C++ exception to rethrow.
+ */
+template <std::size_t I>
+void tryCatch(const py::module_ &pyModule, std::exception_ptr pexc) {
+  try {
+    if constexpr (I == 0) {
       std::rethrow_exception(std::move(pexc));
-    } catch (const ConfigurationException &exc) {
-      setPyException("ConfigurationException", exc);
-    } catch (const InputValidationException &exc) {
-      setPyException("InputValidationException", exc);
-    } catch (const NotImplementedException &exc) {
-      setPyException("NotImplementedException", exc);
-    } catch (const UnhandledException &exc) {
-      setPyException("UnhandledException", exc);
-    } catch (const OpenAssetIOException &exc) {
-      setPyException("OpenAssetIOException", exc);
+    } else {
+      tryCatch<I - 1>(pyModule, std::move(pexc));
     }
-  });
+  } catch (const CppExceptionsAndPyClassNames::Exceptions<I> &cppExc) {
+    setPyException(cppExc, pyModule, CppExceptionsAndPyClassNames::kClassNames[I]);
+  }
 }
 
-void registerBatchElementException(const py::module &mod) {
-  // Pybind has very limited support for custom exception types. This is
-  // a well-known tricky issue and is apparently not on the roadmap to
-  // fix. The only direct support is for an exception type that takes a
-  // single string parameter (message). However, we need `index` and
-  // `error` parameters to mirror C++.
-  //
-  // A way forward is provided by the sketch in:
-  // https://github.com/pybind/pybind11/issues/1281#issuecomment-1375950333
-  //
-  // We execute a Python string literal to create our exception
-  // type. The `globals` and `locals` dict parameters dictate the scope
-  // of execution, so we use this to ensure the definition is scoped to
-  // our `_openassetio` module.
+/// Mapping of Python class name to Python class object.
+using PyObjectByName = std::unordered_map<std::string_view, const py::object>;
+
+/**
+ * For a given C++ exception, find the Python class name of its base
+ * class in the CppExceptionsAndPyClassNames list.
+ *
+ * Recursively iterates through the CppExceptionsAndPyClassNames list
+ * searching for the first base class of @p Exception and returns the
+ * Python class name of it. This works since the list must be sorted
+ * from most-derived to least-derived.
+ *
+ * @tparam Exception Exception to find base class for.
+ * @tparam I Current index in the CppExceptionsAndPyClassNames list to
+ * consider.
+ * @tparam J Remaining indices in the CppExceptionsAndPyClassNames list
+ * to consider if class at @p I is not a base class.
+ * @return Python class name corresponding to the most-derived base
+ * class of @p Exception.
+ */
+template <class Exception, std::size_t I, std::size_t... J>
+constexpr std::string_view findPyClassNameOfCppBaseClass(
+    [[maybe_unused]] std::index_sequence<I, J...> unused) {
+  using CandidateBase = CppExceptionsAndPyClassNames::Exceptions<I>;
+
+  if constexpr (std::is_base_of_v<CandidateBase, Exception> &&
+                !std::is_same_v<CandidateBase, Exception>) {
+    return CppExceptionsAndPyClassNames::kClassNames[I];
+  } else {
+    return findPyClassNameOfCppBaseClass<Exception>(std::index_sequence<J...>{});
+  }
+}
 
-  py::exec(R"pybind(
+/**
+ * Register a new Python exception.
+ *
+ * @tparam Exception Corresponding C++ exception type.
+ * @param pyClassName Python exception class name to register.
+ * @param mod Python module to hold new Python exception class.
+ * @param registeredExceptions Previously registered exceptions, to use
+ * when looking up Python base class to inherit from, and to add this
+ * exception to once registered.
+ */
+template <class Exception>
+void registerPyExceptionClass(const std::string_view pyClassName, const py::module_ &mod,
+                              PyObjectByName &registeredExceptions) {
+  // Register the exception via pybind11.
+  py::object pyExc = [&] {
+    if constexpr (std::is_same_v<Exception, OpenAssetIOException>) {
+      // Specialisation for OpenAssetIOException root base class, which
+      // inherits from built-in Python RuntimeError exception.
+      return py::exception<void /* unused */>{mod, pyClassName.data(), PyExc_RuntimeError};
+    } else if constexpr (std::is_same_v<Exception, BatchElementException>) {
+      // Specialisation for BatchElementException, which must be handled
+      // as a special case due to its non-standard constructor
+      // signature.
+      //
+      // pybind11 has very limited support for custom exception types.
+      // This is a well-known tricky issue and is apparently not on the
+      // roadmap to fix. The only direct support is for an exception
+      // type that takes a single string parameter (message). However,
+      // we need `index` and `error` parameters to mirror the C++
+      // BatchElementException.
+      //
+      // A way forward is provided by the sketch in:
+      // https://github.com/pybind/pybind11/issues/1281#issuecomment-1375950333
+      //
+      // We execute a Python string literal to create our exception
+      // type. The `globals` and `locals` dict parameters dictate the
+      // scope of execution, so we use this to ensure the definition is
+      // scoped to the correct Python module.
+      py::exec(R"pybind(
 class BatchElementException(OpenAssetIOException):
     def __init__(self, index: int, error, message: str):
         self.index = index
         self.error = error
         self.message = message
         super().__init__(message))pybind",
-           mod.attr("__dict__"), mod.attr("__dict__"));
+               mod.attr("__dict__"), mod.attr("__dict__"));
+
+      // Retrieve a handle to the exception type just created by executing
+      // the string literal above.
+      return mod.attr("BatchElementException");
+
+    } else {
+      // General case of a simple exception (only takes a single string
+      // constructor argument) with an OpenAssetIOException-derived base
+      // class.
+
+      // Locate the Python base class name. Assumes the Python class
+      // hierarchy mirrors C++.
+      static constexpr std::string_view kPyBaseClassName =
+          findPyClassNameOfCppBaseClass<Exception>(CppExceptionsAndPyClassNames::kIndices);
+      // Look up the Python base class object.
+      const py::object pyBaseClass = registeredExceptions.at(kPyBaseClassName);
+      // Register the exception, inheriting from looked-up base.
+      return py::exception<void /* unused */>{mod, pyClassName.data(), pyBaseClass};
+    }
+  }();
+  // Ensure this class can be used in subsequent base class lookups, in
+  // case this class is a base of another.
+  registeredExceptions.insert({pyClassName, std::move(pyExc)});
+}
 
-  // Retrieve a handle the the exception type just created by executing
-  // the string literal above.
-  const py::object pyBatchElementException = mod.attr("BatchElementException");
+/**
+ * Register Python exceptions corresponding to C++ exceptions in the
+ * CppExceptionsAndPyClassNames list (looked up by indices).
+ *
+ * @tparam I Indices of exceptions in CppExceptionsAndPyClassNames.
+ * @param mod Python module to hold new Python exception classes.
+ */
+template <std::size_t... I>
+void registerPyExceptionClasses(const py::module &mod,
+                                [[maybe_unused]] std::index_sequence<I...> unused) {
+  // Temporarily keep track of registered exceptions as we iterate
+  // through, so subclasses can retrieve base class objects to derive
+  // from.
+  PyObjectByName registeredExceptions;
+  // Note: must reverse order of iteration through
+  // CppExceptionsAndPyClassNames, since base classes must be registered
+  // first so that they're subsequently available in
+  // registeredExceptions.
+  (registerPyExceptionClass<CppExceptionsAndPyClassNames::Exceptions<sizeof...(I) - I - 1>>(
+       CppExceptionsAndPyClassNames::kClassNames[sizeof...(I) - I - 1], mod, registeredExceptions),
+   ...);
+}
+}  // namespace
 
-  using openassetio::errors::BatchElementException;
+/**
+ * Register Python exceptions with pybind11.
+ *
+ * @param mod Python module to hold new Python exception classes.
+ */
+void registerExceptions(const py::module &mod) {
+  // Ensure module name matches what we expect, since it must be
+  // imported by name in `register_exception_translator`, below.
+  assert(mod.attr("__name__").cast<std::string>() ==
+         openassetio::python::exceptions::kErrorsModuleName);
+  // Register new Python exception types. Note that this is not
+  // sufficient to cause C++ exceptions to be translated. See
+  // `register_exception_translator` below.
+  registerPyExceptionClasses(mod, CppExceptionsAndPyClassNames::kIndices);
 
   // Register a function that will translate our C++ exceptions to the
   // appropriate Python exception type.
   //
   // Note that capturing lambdas are not allowed here, so we must
-  // `import` the exception type in the body of the function.
+  // `import` the module containing the exceptions in the body of the
+  // function.
   py::register_exception_translator([](std::exception_ptr pexc) {
     if (!pexc) {
       return;
     }
-    const py::module_ pyModule = py::module_::import("openassetio._openassetio.errors");
-
-    // Use CPython's PyErr_SetObject to set a custom exception type as
-    // the currently active Python exception in this thread.
-    const auto setPyException = [&pyModule](const char *pyTypeName, const auto &exc) {
-      const py::object pyClass = pyModule.attr(pyTypeName);
-      const py::object pyInstance = pyClass(exc.index, exc.error, exc.what());
-      PyErr_SetObject(pyClass.ptr(), pyInstance.ptr());
-    };
+    const py::module_ pyModule =
+        py::module_::import(openassetio::python::exceptions::kErrorsModuleName.data());
 
     // Handle the different possible C++ exceptions, creating the
     // corresponding Python exception and setting it as the active
     // exception in this thread.
-    try {
-      std::rethrow_exception(std::move(pexc));
-    } catch (const BatchElementException &exc) {
-      setPyException("BatchElementException", exc);
-    }
+    tryCatch<CppExceptionsAndPyClassNames::kSize - 1>(pyModule, std::move(pexc));
   });
 }
-}  // namespace
 
-void registerExceptions(const py::module &mod) {
-  registerNonBatchExceptions(mod);
-  // Make sure the non batch exception is registered first, as that
-  // includes the shared base exception.
-  registerBatchElementException(mod);
-}
+/**
+ * @}
+ */
diff --git a/src/openassetio-python/cmodule/src/hostApi/HostInterfaceBinding.cpp b/src/openassetio-python/cmodule/src/hostApi/HostInterfaceBinding.cpp
index cd7ce24dc..e43195710 100644
--- a/src/openassetio-python/cmodule/src/hostApi/HostInterfaceBinding.cpp
+++ b/src/openassetio-python/cmodule/src/hostApi/HostInterfaceBinding.cpp
@@ -7,6 +7,7 @@
 #include <openassetio/typedefs.hpp>
 
 #include "../_openassetio.hpp"
+#include "../overrideMacros.hpp"
 
 namespace openassetio {
 inline namespace OPENASSETIO_CORE_ABI_VERSION {
@@ -28,7 +29,7 @@ struct PyHostInterface : HostInterface {
   }
 
   [[nodiscard]] InfoDictionary info() override {
-    PYBIND11_OVERRIDE(InfoDictionary, HostInterface, info, /* no args */);
+    OPENASSETIO_PYBIND11_OVERRIDE(InfoDictionary, HostInterface, info, /* no args */);
   }
 };
 
diff --git a/src/openassetio-python/cmodule/src/hostApi/ManagerImplementationFactoryInterfaceBinding.cpp b/src/openassetio-python/cmodule/src/hostApi/ManagerImplementationFactoryInterfaceBinding.cpp
index 644b91612..63c6ae0ff 100644
--- a/src/openassetio-python/cmodule/src/hostApi/ManagerImplementationFactoryInterfaceBinding.cpp
+++ b/src/openassetio-python/cmodule/src/hostApi/ManagerImplementationFactoryInterfaceBinding.cpp
@@ -9,6 +9,7 @@
 
 #include "../PyRetainingSharedPtr.hpp"
 #include "../_openassetio.hpp"
+#include "../overrideMacros.hpp"
 
 namespace openassetio {
 inline namespace OPENASSETIO_CORE_ABI_VERSION {
diff --git a/src/openassetio-python/cmodule/src/log/LoggerInterfaceBinding.cpp b/src/openassetio-python/cmodule/src/log/LoggerInterfaceBinding.cpp
index e8514868f..96c53d134 100644
--- a/src/openassetio-python/cmodule/src/log/LoggerInterfaceBinding.cpp
+++ b/src/openassetio-python/cmodule/src/log/LoggerInterfaceBinding.cpp
@@ -6,6 +6,8 @@
 #include <openassetio/log/LoggerInterface.hpp>
 
 #include "../_openassetio.hpp"
+#include "../overrideMacros.hpp"
+
 namespace openassetio {
 inline namespace OPENASSETIO_CORE_ABI_VERSION {
 namespace log {
diff --git a/src/openassetio-python/cmodule/src/managerApi/EntityReferencePagerInterfaceBinding.cpp b/src/openassetio-python/cmodule/src/managerApi/EntityReferencePagerInterfaceBinding.cpp
index 75cf2231b..d38fcc92f 100644
--- a/src/openassetio-python/cmodule/src/managerApi/EntityReferencePagerInterfaceBinding.cpp
+++ b/src/openassetio-python/cmodule/src/managerApi/EntityReferencePagerInterfaceBinding.cpp
@@ -3,14 +3,12 @@
 #include <pybind11/functional.h>
 #include <pybind11/stl.h>
 
-#include <openassetio/Context.hpp>
-#include <openassetio/InfoDictionary.hpp>
 #include <openassetio/managerApi/EntityReferencePagerInterface.hpp>
 #include <openassetio/managerApi/HostSession.hpp>
-#include <openassetio/trait/collection.hpp>
 #include <openassetio/typedefs.hpp>
 
 #include "../_openassetio.hpp"
+#include "../overrideMacros.hpp"
 
 namespace openassetio {
 inline namespace OPENASSETIO_CORE_ABI_VERSION {
@@ -37,7 +35,7 @@ struct PyEntityReferencePagerInterface : EntityReferencePagerInterface {
   }
 
   void close(const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE(void, EntityReferencePagerInterface, close, hostSession);
+    OPENASSETIO_PYBIND11_OVERRIDE(void, EntityReferencePagerInterface, close, hostSession);
   }
 };
 
diff --git a/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp b/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
index a1ebd437e..16814842b 100644
--- a/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
+++ b/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
@@ -15,6 +15,7 @@
 #include <openassetio/typedefs.hpp>
 
 #include "../_openassetio.hpp"
+#include "../overrideMacros.hpp"
 
 namespace openassetio {
 inline namespace OPENASSETIO_CORE_ABI_VERSION {
@@ -38,61 +39,65 @@ struct PyManagerInterface : ManagerInterface {
   }
 
   [[nodiscard]] InfoDictionary info() override {
-    PYBIND11_OVERRIDE(InfoDictionary, ManagerInterface, info, /* no args */);
+    OPENASSETIO_PYBIND11_OVERRIDE(InfoDictionary, ManagerInterface, info, /* no args */);
   }
 
   [[nodiscard]] InfoDictionary settings(const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE(InfoDictionary, ManagerInterface, settings, hostSession);
+    OPENASSETIO_PYBIND11_OVERRIDE(InfoDictionary, ManagerInterface, settings, hostSession);
   }
 
   void initialize(InfoDictionary managerSettings, const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE(void, ManagerInterface, initialize, std::move(managerSettings), hostSession);
+    OPENASSETIO_PYBIND11_OVERRIDE(void, ManagerInterface, initialize, std::move(managerSettings),
+                                  hostSession);
   }
 
   void flushCaches(const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE(void, ManagerInterface, flushCaches, hostSession);
+    OPENASSETIO_PYBIND11_OVERRIDE(void, ManagerInterface, flushCaches, hostSession);
   }
 
   [[nodiscard]] trait::TraitsDatas managementPolicy(const trait::TraitSets& traitSets,
                                                     access::PolicyAccess policyAccess,
                                                     const ContextConstPtr& context,
                                                     const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE(trait::TraitsDatas, ManagerInterface, managementPolicy, traitSets,
-                      policyAccess, context, hostSession);
+    OPENASSETIO_PYBIND11_OVERRIDE(trait::TraitsDatas, ManagerInterface, managementPolicy,
+                                  traitSets, policyAccess, context, hostSession);
   }
 
   ManagerStateBasePtr createState(const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE(PyRetainingManagerStateBasePtr, ManagerInterface, createState, hostSession);
+    OPENASSETIO_PYBIND11_OVERRIDE(PyRetainingManagerStateBasePtr, ManagerInterface, createState,
+                                  hostSession);
   }
 
   ManagerStateBasePtr createChildState(const ManagerStateBasePtr& parentState,
                                        const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE(PyRetainingManagerStateBasePtr, ManagerInterface, createChildState,
-                      parentState, hostSession);
+    OPENASSETIO_PYBIND11_OVERRIDE(PyRetainingManagerStateBasePtr, ManagerInterface,
+                                  createChildState, parentState, hostSession);
   }
 
   Str persistenceTokenForState(const ManagerStateBasePtr& parentState,
                                const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE(Str, ManagerInterface, persistenceTokenForState, parentState, hostSession);
+    OPENASSETIO_PYBIND11_OVERRIDE(Str, ManagerInterface, persistenceTokenForState, parentState,
+                                  hostSession);
   }
 
   ManagerStateBasePtr stateFromPersistenceToken(const Str& token,
                                                 const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE(PyRetainingManagerStateBasePtr, ManagerInterface, stateFromPersistenceToken,
-                      token, hostSession);
+    OPENASSETIO_PYBIND11_OVERRIDE(PyRetainingManagerStateBasePtr, ManagerInterface,
+                                  stateFromPersistenceToken, token, hostSession);
   }
 
   [[nodiscard]] bool isEntityReferenceString(const Str& someString,
                                              const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE(bool, ManagerInterface, isEntityReferenceString, someString, hostSession);
+    OPENASSETIO_PYBIND11_OVERRIDE(bool, ManagerInterface, isEntityReferenceString, someString,
+                                  hostSession);
   }
 
   void entityExists(const EntityReferences& entityReferences, const ContextConstPtr& context,
                     const HostSessionPtr& hostSession,
                     const ExistsSuccessCallback& successCallback,
                     const BatchElementErrorCallback& errorCallback) override {
-    PYBIND11_OVERRIDE(void, ManagerInterface, entityExists, entityReferences, context, hostSession,
-                      successCallback, errorCallback);
+    OPENASSETIO_PYBIND11_OVERRIDE(void, ManagerInterface, entityExists, entityReferences, context,
+                                  hostSession, successCallback, errorCallback);
   }
 
   [[nodiscard]] bool hasCapability(ManagerInterface::Capability capability) override {
@@ -101,15 +106,17 @@ struct PyManagerInterface : ManagerInterface {
 
   [[nodiscard]] StrMap updateTerminology(StrMap terms,
                                          const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE(StrMap, ManagerInterface, updateTerminology, std::move(terms), hostSession);
+    OPENASSETIO_PYBIND11_OVERRIDE(StrMap, ManagerInterface, updateTerminology, std::move(terms),
+                                  hostSession);
   }
 
   void resolve(const EntityReferences& entityReferences, const trait::TraitSet& traitSet,
                const access::ResolveAccess resolveAccess, const ContextConstPtr& context,
                const HostSessionPtr& hostSession, const ResolveSuccessCallback& successCallback,
                const BatchElementErrorCallback& errorCallback) override {
-    PYBIND11_OVERRIDE(void, ManagerInterface, resolve, entityReferences, traitSet, resolveAccess,
-                      context, hostSession, successCallback, errorCallback);
+    OPENASSETIO_PYBIND11_OVERRIDE(void, ManagerInterface, resolve, entityReferences, traitSet,
+                                  resolveAccess, context, hostSession, successCallback,
+                                  errorCallback);
   }
 
   void defaultEntityReference(const trait::TraitSets& traitSets,
@@ -117,8 +124,9 @@ struct PyManagerInterface : ManagerInterface {
                               const ContextConstPtr& context, const HostSessionPtr& hostSession,
                               const DefaultEntityReferenceSuccessCallback& successCallback,
                               const BatchElementErrorCallback& errorCallback) override {
-    PYBIND11_OVERRIDE(void, ManagerInterface, defaultEntityReference, traitSets,
-                      defaultEntityAccess, context, hostSession, successCallback, errorCallback);
+    OPENASSETIO_PYBIND11_OVERRIDE(void, ManagerInterface, defaultEntityReference, traitSets,
+                                  defaultEntityAccess, context, hostSession, successCallback,
+                                  errorCallback);
   }
 
   void getWithRelationship(
@@ -156,17 +164,18 @@ struct PyManagerInterface : ManagerInterface {
                  const HostSessionPtr& hostSession,
                  const PreflightSuccessCallback& successCallback,
                  const BatchElementErrorCallback& errorCallback) override {
-    PYBIND11_OVERRIDE(void, ManagerInterface, preflight, entityReferences, traitsHints,
-                      publishingAccess, context, hostSession, successCallback, errorCallback);
+    OPENASSETIO_PYBIND11_OVERRIDE(void, ManagerInterface, preflight, entityReferences, traitsHints,
+                                  publishingAccess, context, hostSession, successCallback,
+                                  errorCallback);
   }
 
   void register_(const EntityReferences& entityReferences, const trait::TraitsDatas& traitsDatas,
                  const access::PublishingAccess publishingAccess, const ContextConstPtr& context,
                  const HostSessionPtr& hostSession, const RegisterSuccessCallback& successCallback,
                  const BatchElementErrorCallback& errorCallback) override {
-    PYBIND11_OVERRIDE_NAME(void, ManagerInterface, "register", register_, entityReferences,
-                           traitsDatas, publishingAccess, context, hostSession, successCallback,
-                           errorCallback);
+    OPENASSETIO_PYBIND11_OVERRIDE_NAME(void, ManagerInterface, "register", register_,
+                                       entityReferences, traitsDatas, publishingAccess, context,
+                                       hostSession, successCallback, errorCallback);
   }
 
   // Hoist protected members
diff --git a/src/openassetio-python/cmodule/src/overrideMacros.hpp b/src/openassetio-python/cmodule/src/overrideMacros.hpp
new file mode 100644
index 000000000..686141210
--- /dev/null
+++ b/src/openassetio-python/cmodule/src/overrideMacros.hpp
@@ -0,0 +1,92 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2023 The Foundry Visionmongers Ltd
+#pragma once
+#include <pybind11/pybind11.h>
+
+#include <openassetio/private/python/exceptions.hpp>
+
+/// @note Update errorsTest.cpp if adding more override macros below.
+
+/**
+ * Decorate PYBIND11_OVERRIDE_NAME with exception type translation.
+ */
+#define OPENASSETIO_PYBIND11_OVERRIDE_NAME(ret_type, cname, name, fn, ...)            \
+  do {                                                                                \
+    /* Must explicitly specify decorated lambda return type, since    */              \
+    /* PYBIND11_OVERRIDE_IMPL return type can be PyRetainingSharedPtr,*/              \
+    /* which confuses the compiler.                                   */              \
+    return openassetio::python::exceptions::decorateWithExceptionConverter(           \
+        [&]() -> decltype(cname::fn(__VA_ARGS__)) {                                   \
+          PYBIND11_OVERRIDE_IMPL(PYBIND11_TYPE(ret_type), PYBIND11_TYPE(cname), name, \
+                                 __VA_ARGS__);                                        \
+          return cname::fn(__VA_ARGS__);                                              \
+        });                                                                           \
+  } while (false)
+
+/**
+ * Decorate PYBIND11_OVERRIDE with exception type translation.
+ */
+#define OPENASSETIO_PYBIND11_OVERRIDE(ret_type, cname, fn, ...)                              \
+  OPENASSETIO_PYBIND11_OVERRIDE_NAME(PYBIND11_TYPE(ret_type), PYBIND11_TYPE(cname), #fn, fn, \
+                                     __VA_ARGS__)
+
+/**
+ * Similar to OPENASSETIO_PYBIND11_OVERRIDE, but allows different
+ * arguments to be given to the Python implementation and the C++
+ * fallback (base class) implementation.
+ *
+ * For example, this is useful to decorate std::function callbacks
+ * with `PyRetainingSharedPtr`s, so that Python objects are kept alive
+ * as they pass through C++ callbacks.
+ *
+ * The CppArgs parameter must be a parentheses-enclosed,
+ * comma-separated, list of arguments. These are given to the C++ base
+ * class implementation, if no Python override is found.
+ *
+ * All remaining arguments following the CppArgs (not
+ * parentheses-enclosed) are passed to the Python override
+ * implementation, if one exists.
+ */
+#define OPENASSETIO_PYBIND11_OVERRIDE_ARGS(Ret, Class, Fn, CppArgs, ... /* PyArgs */)         \
+  do {                                                                                        \
+    return openassetio::python::exceptions::decorateWithExceptionConverter(                   \
+        [&]() -> decltype(Class::Fn CppArgs) {                                                \
+          PYBIND11_OVERRIDE_IMPL(PYBIND11_TYPE(Ret), PYBIND11_TYPE(Class), #Fn, __VA_ARGS__); \
+          return Class::Fn CppArgs;                                                           \
+        });                                                                                   \
+  } while (false)
+
+/**
+ * Work around https://github.com/pybind/pybind11/issues/4878 and
+ * decorate PYBIND11_OVERRIDE_PURE_NAME with exception type translation.
+ *
+ * In a Debug build, where `assert` is enabled, pybind11 will do an
+ * `assert(!PyErr_Occurred())` before throwing "Tried to call pure
+ * virtual function". Since Python 3.9, the PyErr_Occurred() function
+ * requires the GIL to be acquired.
+ *
+ * The following macro duplicates PYBIND11_OVERRIDE_PURE_NAME and
+ * inserts a `gil_scoped_acquire`, in order to work around the problem
+ * until it is fixed upstream.
+ *
+ * @todo Revert GIL workaround once upstream fix is available.
+ */
+#define OPENASSETIO_PYBIND11_OVERRIDE_PURE_NAME(ret_type, cname, name, fn, ...)                   \
+  do {                                                                                            \
+    return openassetio::python::exceptions::decorateWithExceptionConverter(                       \
+        [&]() -> decltype(cname::fn(__VA_ARGS__)) {                                               \
+          PYBIND11_OVERRIDE_IMPL(PYBIND11_TYPE(ret_type), PYBIND11_TYPE(cname), name,             \
+                                 __VA_ARGS__);                                                    \
+          const pybind11::gil_scoped_acquire gil{};                                               \
+          pybind11::pybind11_fail(                                                                \
+              "Tried to call pure virtual function \"" PYBIND11_STRINGIFY(cname) "::" name "\""); \
+        });                                                                                       \
+  } while (false)
+
+/**
+ * Work around https://github.com/pybind/pybind11/issues/4878 and
+ * decorate PYBIND11_OVERRIDE_PURE with exception type translation.
+ */
+#define OPENASSETIO_PYBIND11_OVERRIDE_PURE(ret_type, cname, fn, ...)                              \
+  OPENASSETIO_PYBIND11_OVERRIDE_PURE_NAME(PYBIND11_TYPE(ret_type), PYBIND11_TYPE(cname), #fn, fn, \
+                                          __VA_ARGS__)
diff --git a/src/openassetio-python/private/include/openassetio/private/python/exceptions.hpp b/src/openassetio-python/private/include/openassetio/private/python/exceptions.hpp
new file mode 100644
index 000000000..bbfc15f89
--- /dev/null
+++ b/src/openassetio-python/private/include/openassetio/private/python/exceptions.hpp
@@ -0,0 +1,235 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2023 The Foundry Visionmongers Ltd
+#pragma once
+#include <string>
+#include <string_view>
+#include <tuple>
+#include <type_traits>
+
+#include <pybind11/pybind11.h>
+
+#include <openassetio/export.h>
+#include <openassetio/errors/exceptions.hpp>
+
+namespace openassetio {
+inline namespace OPENASSETIO_CORE_ABI_VERSION {
+namespace python::exceptions {
+namespace py = pybind11;
+using openassetio::errors::BatchElementException;
+using openassetio::errors::ConfigurationException;
+using openassetio::errors::InputValidationException;
+using openassetio::errors::NotImplementedException;
+using openassetio::errors::OpenAssetIOException;
+using openassetio::errors::UnhandledException;
+
+/**
+ * @section list List of exceptions.
+ *
+ * @{
+ */
+
+/**
+ * Container for a list of types and their corresponding string
+ * identifiers.
+ *
+ * @tparam Type List of types.
+ */
+template <class... Type>
+struct TypesAndIds {
+  static constexpr std::size_t kSize = sizeof...(Type);
+  using Types = std::tuple<Type...>;
+  const std::array<std::string_view, kSize> ids;
+};
+
+/**
+ * Exhaustive list of all OpenAssetIO-specific C++ exception types and
+ * their corresponding Python class names.
+ *
+ * Note base classes must come _after_ subclasses (asserted at
+ * compile-time, below). This is so that try-catch blocks can be ordered
+ * such that more-derived exceptions come before less-derived. See
+ * `tryCatch`.
+ */
+constexpr auto kCppExceptionsAndPyClassNames =
+    TypesAndIds<BatchElementException, NotImplementedException, UnhandledException,
+                ConfigurationException, InputValidationException, OpenAssetIOException>{
+        "BatchElementException",  "NotImplementedException",  "UnhandledException",
+        "ConfigurationException", "InputValidationException", "OpenAssetIOException"};
+
+/**
+ * Convenience struct deriving compile-time properties from the above
+ * list of OpenAssetIO-specific exception class types and names.
+ *
+ * In particular there are two lists, such that given a single index,
+ * the C++ exception class or the Python class name can be queried at
+ * compile time.
+ */
+struct CppExceptionsAndPyClassNames {
+  using Type = decltype(kCppExceptionsAndPyClassNames);
+  /// "Array" of C++ exception classes.
+  template <std::size_t I>
+  using Exceptions = std::tuple_element_t<I, typename Type::Types>;
+  /// Array of Python exception class names.
+  static constexpr std::array kClassNames = kCppExceptionsAndPyClassNames.ids;
+  /// Total number of exceptions in the list(s).
+  static constexpr std::size_t kSize = Type::kSize;
+  /**
+   * Convenience for a sequence of indices, used for compile-time
+   * iteration over C++ exception classes.
+   */
+  static constexpr auto kIndices = std::make_index_sequence<kSize>{};
+};
+
+/**
+ * @}
+ */
+
+/**
+ * @section tocpp Conversion from Python exception to C++ exception.
+ *
+ * @{
+ */
+
+/**
+ * Hybrid of OpenAssetIO and pybind11 exception class.
+ *
+ * Multiple inheritance means that in C++ we can `catch` this exception
+ * as either `error_already_set` or the given OpenAssetIO exception. If
+ * (re)thrown, it can be caught further up the stack (again) as either
+ * of the two exceptions. In this way we can satisfy the two use-cases:
+ * - Catching the exception in C++ as an OpenAssetIO C++ exception.
+ * - Allowing the exception to propagate back to Python via pybind11,
+ *   which will translate it back into a Python exception (see
+ *   registerExceptions).
+ *
+ * Note that calling `.what()` on this exception is ambiguous and will
+ * cause a compiler error. However, this is a good thing, since this
+ * exception should never appear in a `catch` (should only be caught as
+ * one of the base classes).
+ *
+ * Generalisation assuming that the given exception type is a simple
+ * exception that takes a string message as its only constructor
+ * argument.
+ *
+ * @tparam CppException C++ exception type corresponding to given
+ * Python exception.
+ */
+template <class CppException>
+struct HybridException : py::error_already_set, CppException {
+  explicit HybridException(const py::error_already_set &pyExc)
+      : py::error_already_set{pyExc}, CppException{pyExc.what()} {}
+};
+
+/**
+ * Hybrid of OpenAssetIO and pybind11 exception class.
+ *
+ * Specialisation for more complex case of BatchElementException. See
+ * generalisation, above, for more details.
+ */
+template <>
+struct HybridException<BatchElementException> : py::error_already_set, BatchElementException {
+  explicit HybridException(const py::error_already_set &pyExc)
+      : py::error_already_set{pyExc},
+        BatchElementException{
+            py::cast<std::size_t>(pyExc.value().attr("index")),
+            py::cast<openassetio::errors::BatchElementError>(pyExc.value().attr("error")),
+            pyExc.what()} {}
+};
+
+/**
+ * Throw a HybridException if the given Python class name matches the
+ * given expected Python class name corresponding to the given C++
+ * exception type.
+ *
+ * @tparam Exception C++ exception to wrap in a HybridException.
+ * @param expectedPyExcName Python exception class name corresponding
+ * to @p Exception class.
+ * @param thrownPyExc pybind11-wrapped Python exception to potentially
+ * wrap in a HybridException.
+ * @param thrownPyExcName Python exception class name of @p thrownPyExc
+ * to test against @p expectedPyExcName.
+ */
+template <class Exception>
+void throwHybridExceptionIfMatches(const std::string_view expectedPyExcName,
+                                   const py::error_already_set &thrownPyExc,
+                                   const std::string_view thrownPyExcName) {
+  if (thrownPyExcName == expectedPyExcName) {
+    throw HybridException<Exception>{thrownPyExc};
+  }
+}
+
+/// Name of errors module where exceptions will be registered.
+constexpr std::string_view kErrorsModuleName = "openassetio._openassetio.errors";
+
+/**
+ * Attempt to convert a given Python exception into one of the C++
+ * exceptions in the CppExceptionsAndPyClassNames list (looked up by
+ * indices) and throw it.
+ *
+ * A no-op if no exception matches.
+ *
+ * @tparam I Indices of exceptions in the CppExceptionsAndPyClassNames
+ * list to attempt to convert to.
+ * @param thrownPyExc pybind11-wrapped Python exception.
+ */
+template <std::size_t... I>
+void convertPyExceptionAndThrow(const py::error_already_set &thrownPyExc,
+                                [[maybe_unused]] std::index_sequence<I...> unused) {
+  // We need values from the Python exception object, so must hold the
+  // GIL. py::error_already_set::what() does this itself, but we need
+  // additional attributes too. Note that acquiring the GIL can cause
+  // crashes if the Python interpreter is finalizing (i.e. has been
+  // destroyed).
+  const py::gil_scoped_acquire gil{};
+  // Check module name of Python exception.
+  if (thrownPyExc.type().attr("__module__").cast<std::string_view>() != kErrorsModuleName) {
+    // Just in case another exception is defined by managers/hosts with
+    // the same name in a different namespace.
+    return;
+  }
+
+  // Extract class name of Python exception.
+  const std::string thrownPyExcName{py::str{thrownPyExc.type().attr("__name__")}};
+
+  (throwHybridExceptionIfMatches<CppExceptionsAndPyClassNames::Exceptions<I>>(
+       CppExceptionsAndPyClassNames::kClassNames[I], thrownPyExc, thrownPyExcName),
+   ...);
+}
+
+/**
+ * Attempt to convert a given Python exception into a C++ exception
+ * in the CppExceptionsAndPyClassNames list, and throw it.
+ *
+ * A no-op if no exception matches.
+ *
+ * @param thrownPyExc pybind11-wrapped Python exception.
+ */
+inline void convertPyExceptionAndThrow(const py::error_already_set &thrownPyExc) {
+  convertPyExceptionAndThrow(thrownPyExc, CppExceptionsAndPyClassNames::kIndices);
+}
+
+/**
+ * Decorate a callable with a try-catch that will catch a
+ * pybind11-wrapped Python exception and attempt to convert it to a C++
+ * exception before re-throwing.
+ *
+ * @tparam Fn Type of callable to decorate.
+ * @param func Callable instance to decorate.
+ * @return Return value of callable, if no exception occurs.
+ */
+template <class Fn>
+auto decorateWithExceptionConverter(Fn &&func) {
+  try {
+    return func();
+  } catch (const py::error_already_set &exc) {
+    convertPyExceptionAndThrow(exc);
+    // Can't convert exception, so rethrow as-is.
+    throw;
+  }
+}
+/**
+ * @}
+ */
+}  // namespace python::exceptions
+}  // namespace OPENASSETIO_CORE_ABI_VERSION
+}  // namespace openassetio


Test patch:
diff --git a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/errorsTest.cpp b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/errorsTest.cpp
index f33970024..38313a8ba 100644
--- a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/errorsTest.cpp
+++ b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/errorsTest.cpp
@@ -9,38 +9,326 @@
 
 #include <pybind11/pybind11.h>
 
+#include <overrideMacros.hpp>
+
 #include <openassetio/errors/BatchElementError.hpp>
 #include <openassetio/errors/exceptions.hpp>
+
 namespace py = pybind11;
 
 namespace {
+using openassetio::python::exceptions::CppExceptionsAndPyClassNames;
+
+/**
+ * Throw given exception type.
+ *
+ * Generalisation for simple exceptions that take a single string
+ * message constructor argument.
+ *
+ * @tparam Exception Exception type to throw.
+ * @param msg Message to set on exception.
+ */
+template <class Exception>
+void throwException(const std::string& msg) {
+  throw Exception{msg};
+}
+
+using openassetio::errors::BatchElementException;
+/**
+ * Throw a BatchElementException.
+ *
+ * Specialisation to handle the more complex case of
+ * BatchElementException constructor.
+ *
+ * @param msg Message to set on BatchElementException.
+ */
+template <>
+void throwException<BatchElementException>(const std::string& msg) {
+  using openassetio::errors::BatchElementError;
+  auto error = BatchElementError{BatchElementError::ErrorCode::kEntityAccessError, "errorMessage"};
+  throw BatchElementException(0, std::move(error), openassetio::Str{msg});
+}
+
+/**
+ * Throw an exception from the CppExceptionsAndPyClassNames list (looked
+ * up by index) if the given name matches the exception's name.
+ *
+ * @tparam I Index in CppExceptionsAndPyClassNames of exception to
+ * potentially throw.
+ * @param exceptionName Name of exception to potentially throw.
+ * @param msg Message to set on exception, if thrown.
+ */
+template <std::size_t I>
 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
-void throwException(const std::string& exceptionName, const std::string& msgData = "") {
-  if (exceptionName == "OpenAssetIOException") {
-    throw openassetio::errors::OpenAssetIOException(msgData);
-  }
-  if (exceptionName == "InputValidationException") {
-    throw openassetio::errors::InputValidationException(msgData);
-  }
-  if (exceptionName == "ConfigurationException") {
-    throw openassetio::errors::ConfigurationException(msgData);
+void throwIfMatches(const std::string& exceptionName, const std::string& msg) {
+  if (exceptionName == CppExceptionsAndPyClassNames::kClassNames[I]) {
+    throwException<CppExceptionsAndPyClassNames::Exceptions<I>>(msg);
   }
-  if (exceptionName == "NotImplementedException") {
-    throw openassetio::errors::NotImplementedException(msgData);
+}
+
+/**
+ * Throw an exception from the CppExceptionsAndPyClassNames list that
+ * matches the given exception name.
+ *
+ * @tparam I Indices in CppExceptionsAndPyClassNames to iterate through
+ * looking for a matching exception.
+ * @param exceptionName Name of exception to match.
+ * @param msg Message to set in thrown exception.
+ */
+template <std::size_t... I>
+void throwMatchingException(const std::string& exceptionName, const std::string& msg,
+                            [[maybe_unused]] std::index_sequence<I...> unused) {
+  (throwIfMatches<I>(exceptionName, msg), ...);
+}
+
+/**
+ * Throw an exception from the CppExceptionsAndPyClassNames list that
+ * matches the given exception name.
+ *
+ * @param exceptionName Name of exception to throw.
+ * @param msg Message to set in thrown exception.
+ */
+// NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
+void throwException(const std::string& exceptionName, const std::string& msg = "") {
+  throwMatchingException(exceptionName, msg, CppExceptionsAndPyClassNames::kIndices);
+}
+
+/**
+ * Lookup (by index) the exception to catch in the
+ * CppExceptionsAndPyClassNames list and, if its name matches the given
+ * @p catchExceptionName, lookup another exception to throw that matches
+ * the given @p throwExceptionName, and if found, throw the exception to
+ * throw and catch it as the exception to catch.
+ *
+ * @tparam I Index of exception in CppExceptionsAndPyClassNames to
+ * attempt to catch.
+ * @param throwExceptionName Name of exception type to throw.
+ * @param catchExceptionName Name of exception type to catch.
+ * @return `true` if the exception was thrown and caught, false (or
+ * propagate exception) otherwise.
+ */
+template <std::size_t I>
+// NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
+bool throwAndCatchIfMatches(const std::string& throwExceptionName,
+                            const std::string& catchExceptionName) {
+  if (catchExceptionName == CppExceptionsAndPyClassNames::kClassNames[I]) {
+    try {
+      throwException(throwExceptionName, "");
+    } catch (const CppExceptionsAndPyClassNames::Exceptions<I>&) {
+      return true;
+    }
   }
-  if (exceptionName == "UnhandledException") {
-    throw openassetio::errors::UnhandledException(msgData);
+  return false;
+}
+/**
+ * Throw an exception (looked up by name) from the
+ * CppExceptionsAndPyClassNames list and catch as another exception
+ * (looked up by name) from the CppExceptionsAndPyClassNames list.
+ *
+ * @tparam I Indices in the CppExceptionsAndPyClassNames list to
+ * iterate through searching for a matching exception to catch.
+ * @param throwExceptionName Name of exception type to throw.
+ * @param catchExceptionName Name of exception type to catch.
+ * @return `true` if a matching exception was successfully thrown and
+ * caught, `false` otherwise.
+ */
+template <std::size_t... I>
+bool throwAndCatchMatchingException(const std::string& throwExceptionName,
+                                    const std::string& catchExceptionName,
+                                    [[maybe_unused]] std::index_sequence<I...> unused) {
+  return (throwAndCatchIfMatches<I>(throwExceptionName, catchExceptionName) || ...);
+}
+
+/**
+ * Throw an exception (looked up by name) from the
+ * CppExceptionsAndPyClassNames list and catch as another exception
+ * (looked up by name) from the CppExceptionsAndPyClassNames list.
+ *
+ * @param throwExceptionName Name of exception type to throw.
+ * @param catchExceptionName Name of exception type to catch.
+ * @return `true` if a matching exception was successfully thrown and
+ * caught, `false` otherwise.
+ */
+bool throwAndCatch(const std::string& throwExceptionName, const std::string& catchExceptionName) {
+  return throwAndCatchMatchingException(throwExceptionName, catchExceptionName,
+                                        CppExceptionsAndPyClassNames::kIndices);
+}
+
+/**
+ * Lookup (by index) an exception in the CppExceptionsAndPyClassNames
+ * list and, if its name matches the given @p catchExceptionName,
+ * execute a callable wrapped in a try-catch that attempts to catch the
+ * exception.
+ *
+ * @tparam I Index in CppExceptionsAndPyClassNames list of exception to
+ * catch.
+ * @tparam Fn Type of callable to execute.
+ * @param func Callable instance to execute.
+ * @param catchExceptionName Name of exception to catch.
+ * @return `true` if the exception was thrown and caught, false (or
+ * propagate exception) otherwise.
+ */
+template <std::size_t I, class Fn>
+bool executeFnAndCatchIfMatches(Fn&& func, const std::string& catchExceptionName) {
+  if (catchExceptionName == CppExceptionsAndPyClassNames::kClassNames[I]) {
+    try {
+      func();
+    } catch (const CppExceptionsAndPyClassNames::Exceptions<I>&) {
+      return true;
+    }
   }
-  if (exceptionName == "BatchElementException") {
-    auto error = openassetio::errors::BatchElementError{
-        openassetio::errors::BatchElementError::ErrorCode::kEntityAccessError, "errorMessage"};
-    throw openassetio::errors::BatchElementException(0, std::move(error), msgData);
+  return false;
+}
+
+/**
+ * Iterate through the CppExceptionsAndPyClassNames list, until an
+ * exception is found that matches the given @p catchExceptionName, then
+ * execute a callable wrapped in a try-catch that attempts to catch the
+ * exception.
+ *
+ * @tparam I Indices in the CppExceptionsAndPyClassNames list to iterate
+ * through.
+ * @tparam Fn Type of callable to execute.
+ * @param func Callable instance to execute.
+ * @param catchExceptionName Name of exception to catch.
+ * @return `true` if any exception matched and successfully caught the
+ * exception thrown by the callable, `false` (or propagate exception)
+ * otherwise.
+ */
+template <std::size_t... I, class Fn>
+bool executeFnAndCatchMatchingException(Fn&& func, const std::string& catchExceptionName,
+                                        [[maybe_unused]] std::index_sequence<I...> unused) {
+  return (executeFnAndCatchIfMatches<I>(std::forward<Fn>(func), catchExceptionName) || ...);
+}
+
+/**
+ * Execute a callable wrapped in a try-catch that attempts to catch the
+ * exception given by name in @p catchExceptionName.
+ *
+ * @tparam Fn Type of callable to execute.
+ * @param func Callable instance to execute.
+ * @param catchExceptionName Name of exception to catch.
+ * @return `true` if successfully executed and caught the exception
+ * thrown by the callable, `false` (or propagate exception) otherwise.
+ */
+template <class Fn>
+bool executeFnAndCatch(Fn&& func, const std::string& catchExceptionName) {
+  return executeFnAndCatchMatchingException(std::forward<Fn>(func), catchExceptionName,
+                                            CppExceptionsAndPyClassNames::kIndices);
+}
+
+/**
+ * Lookup (by index) an exception in the CppExceptionsAndPyClassNames
+ * list and, if its name matches the given @p catchExceptionName,
+ * execute a callable wrapped in a try-catch that attempts to catch the
+ * exception, then re-throws.
+ *
+ * If the callable throws any non-matching exception, then it is
+ * swallowed and not allowed to propagate.
+ *
+ * @tparam I Index in CppExceptionsAndPyClassNames list of exception to
+ * catch.
+ * @tparam Fn Type of callable to execute.
+ * @param func Callable instance to execute.
+ * @param catchExceptionName Name of exception to catch.
+ */
+template <std::size_t I, class Fn>
+void executeFnAndCatchAndRethrowIfMatches(Fn&& func, const std::string& catchExceptionName) {
+  if (catchExceptionName == CppExceptionsAndPyClassNames::kClassNames[I]) {
+    try {
+      func();
+    } catch (const CppExceptionsAndPyClassNames::Exceptions<I>&) {
+      throw;
+    } catch (...) {
+      // Ensure error_already_set doesn't propagate and cause a false
+      // positive back in the Python test case.
+    }
   }
 }
+
+/**
+ * Iterate through the CppExceptionsAndPyClassNames list, until an
+ * exception is found that matches the given @p catchExceptionName, then
+ * execute a callable wrapped in a try-catch that attempts to catch the
+ * exception and re-throw it.
+ *
+ * If the callable throws an exception that doesn't match @p
+ * catchExceptionName, then that exception is swallowed and not allowed
+ * to propagate.
+ *
+ * @tparam I Indices in the CppExceptionsAndPyClassNames list to iterate
+ * through.
+ * @tparam Fn Type of callable to execute.
+ * @param func Callable instance to execute.
+ * @param catchExceptionName Name of exception to catch.
+ */
+template <std::size_t... I, class Fn>
+void executeFnAndCatchAndRethrowMatchingException(
+    Fn&& func, const std::string& catchExceptionName,
+    [[maybe_unused]] std::index_sequence<I...> unused) {
+  (executeFnAndCatchAndRethrowIfMatches<I>(std::forward<Fn>(func), catchExceptionName), ...);
+}
+
+/**
+ * Execute a callable wrapped in a try-catch that attempts to catch the
+ * exception given by name in @p catchExceptionName, then re-throws it.
+ *
+ * If the callable throws an exception that doesn't match @p
+ * catchExceptionName, then that exception is swallowed and not allowed
+ * to propagate.
+ *
+ * @tparam Fn Type of callable to execute.
+ * @param func Callable instance to execute.
+ * @param catchExceptionName Name of exception to catch.
+ */
+template <class Fn>
+void executeFnAndCatchAndRethrow(Fn&& func, const std::string& catchExceptionName) {
+  executeFnAndCatchAndRethrowMatchingException(std::forward<Fn>(func), catchExceptionName,
+                                               CppExceptionsAndPyClassNames::kIndices);
+}
 }  // namespace
 
 /**
- * Functions to aid testing C++ exceptions and their Python conversion.
+ * Abstract C++ class to be implemented as a subclass in Python, which
+ * is expected to implement each method such that they throw an
+ * exception.
+ *
+ * Each method corresponds to a OpenAssetIO-customized pybind11 override
+ * macro.
+ */
+struct ExceptionThrower {
+  virtual ~ExceptionThrower() = default;
+  virtual void throwFromOverride() {}
+  virtual void throwFromOverridePure() = 0;
+  virtual void throwFromOverrideName() {}
+  virtual void throwFromOverrideArgs() {}
+};
+
+/**
+ * pybind11 trampoline class for ExceptionThrower.
+ *
+ * Each method uses a different override macro to implement its internal
+ * dispatch logic, so that all macros can be tested by calling the
+ * corresponding method.
+ */
+struct PyExceptionThrower : ExceptionThrower {
+  void throwFromOverride() override {
+    OPENASSETIO_PYBIND11_OVERRIDE(void, ExceptionThrower, throwFromOverride, );
+  }
+  void throwFromOverridePure() override {
+    OPENASSETIO_PYBIND11_OVERRIDE_PURE(void, ExceptionThrower, throwFromOverridePure, );
+  }
+  void throwFromOverrideName() override {
+    OPENASSETIO_PYBIND11_OVERRIDE_NAME(void, ExceptionThrower, "throwFromOverrideName",
+                                       throwFromOverrideName, );
+  }
+  void throwFromOverrideArgs() override {
+    OPENASSETIO_PYBIND11_OVERRIDE_ARGS(void, ExceptionThrower, throwFromOverrideArgs, (), );
+  }
+};
+
+/**
+ * Functions to aid testing C++ exceptions and C++<->Python conversion.
  *
  * `throwException` takes an exception by name (matching `cls.__name__`
  * in Python) and throws it, with an optional message. This then allows
@@ -54,57 +342,69 @@ void throwException(const std::string& exceptionName, const std::string& msgData
  * if the catch failed, allows the exception to propagate (failing the
  * pytest). Note that the exception hierarchy in C++ and in Python are
  * configured independently, so testing one does not test the other.
+ *
+ * `isPythonExceptionCatchableAs` instructs Python to throw an exception
+ * and ensures it can be caught as the given C++ exception. It takes an
+ * abstract `ExceptionThrower` instance, whose concrete implementation
+ * is in Python. All methods of the `ExceptionThrower` object are called
+ * and their results combined to ensure that all override macros
+ * implement Python->C++ exception translation. Note that the GIL is
+ * released deliberately in order to better simulate the situation in
+ * OpenAssetIO Python bindings.
+ *
+ * `throwPythonExceptionCatchAsCppExceptionAndRethrow` instructs Python
+ * to throw an exception and ensures it can be caught as the given C++
+ * exception, and then re-throws the C++ exception, so that it can be
+ * caught again in Python as a Python exception. This is technically
+ * not necessary to test, since both C++->Python and Python->C++
+ * exception translation are already tested independently, but it gives
+ * some additional confidence. Note that the GIL is released
+ * deliberately in order to better simulate the situation in OpenAssetIO
+ * Python bindings.
  */
 void registerExceptionThrower(py::module_& mod) {
   mod.def("throwException", [](const std::string& exceptionName, const std::string& msgData) {
     throwException(exceptionName, msgData);
   });
+
   mod.def(
       "isThrownExceptionCatchableAs",
       [](const std::string& throwExceptionName, const std::string& catchExceptionName) {
-        if (catchExceptionName == "OpenAssetIOException") {
-          try {
-            throwException(throwExceptionName);
-          } catch (const openassetio::errors::OpenAssetIOException&) {
-            return true;
-          }
-        }
-        if (catchExceptionName == "InputValidationException") {
-          try {
-            throwException(throwExceptionName);
-          } catch (const openassetio::errors::InputValidationException&) {
-            return true;
-          }
-        }
-        if (catchExceptionName == "ConfigurationException") {
-          try {
-            throwException(throwExceptionName);
-          } catch (const openassetio::errors::ConfigurationException&) {
-            return true;
-          }
-        }
-        if (catchExceptionName == "NotImplementedException") {
-          try {
-            throwException(throwExceptionName);
-          } catch (const openassetio::errors::NotImplementedException&) {
-            return true;
-          }
-        }
-        if (catchExceptionName == "UnhandledException") {
-          try {
-            throwException(throwExceptionName);
-          } catch (const openassetio::errors::UnhandledException&) {
-            return true;
-          }
-        }
-        if (catchExceptionName == "BatchElementException") {
-          try {
-            throwException(throwExceptionName);
-          } catch (const openassetio::errors::BatchElementException&) {
-            return true;
-          }
-        }
-        return false;
+        return throwAndCatch(throwExceptionName, catchExceptionName);
       },
       py::arg("throwExceptionName"), py::arg("catchExceptionName"));
+
+  mod.def(
+      "isPythonExceptionCatchableAs",
+      [](ExceptionThrower& exceptionThrower, const std::string& catchExceptionName) {
+        return executeFnAndCatch([&] { exceptionThrower.throwFromOverride(); },
+                                 catchExceptionName) &&
+               executeFnAndCatch([&] { exceptionThrower.throwFromOverrideArgs(); },
+                                 catchExceptionName) &&
+               executeFnAndCatch([&] { exceptionThrower.throwFromOverrideName(); },
+                                 catchExceptionName) &&
+               executeFnAndCatch([&] { exceptionThrower.throwFromOverridePure(); },
+                                 catchExceptionName);
+      },
+      py::arg("exceptionThrower"), py::arg("catchExceptionName"),
+      py::call_guard<py::gil_scoped_release>{});
+
+  mod.def(
+      "throwPythonExceptionCatchAsCppExceptionAndRethrow",
+      [](ExceptionThrower& exceptionThrower, const std::string& catchExceptionName) {
+        // Arbitrarily use `throwFromOverride`, trusting that the
+        // underlying implementation (i.e.
+        // `decorateWithExceptionConverter`) is the same for all macros.
+        executeFnAndCatchAndRethrow([&] { exceptionThrower.throwFromOverride(); },
+                                    catchExceptionName);
+      },
+      py::arg("exceptionThrower"), py::arg("catchExceptionName"),
+      py::call_guard<py::gil_scoped_release>{});
+
+  py::class_<ExceptionThrower, PyExceptionThrower>{mod, "ExceptionThrower"}
+      .def(py::init<>())
+      .def("throwFromOverride", &ExceptionThrower::throwFromOverride)
+      .def("throwFromOverridePure", &ExceptionThrower::throwFromOverridePure)
+      .def("throwFromOverrideName", &ExceptionThrower::throwFromOverrideName)
+      .def("throwFromOverrideArgs", &ExceptionThrower::throwFromOverrideArgs);
 }
diff --git a/src/openassetio-python/tests/cmodule/test_errors.py b/src/openassetio-python/tests/cmodule/test_errors.py
index 95de43366..78b3a548e 100644
--- a/src/openassetio-python/tests/cmodule/test_errors.py
+++ b/src/openassetio-python/tests/cmodule/test_errors.py
@@ -16,6 +16,8 @@
 """
 Tests of C++ binding utilities for exception types.
 """
+import inspect
+from unittest import mock
 
 # pylint: disable=invalid-name,redefined-outer-name
 # pylint: disable=missing-class-docstring,missing-function-docstring
@@ -25,37 +27,39 @@
 from openassetio import errors
 
 
+all_exceptions = (
+    errors.OpenAssetIOException,
+    errors.ConfigurationException,
+    errors.InputValidationException,
+    errors.NotImplementedException,
+    errors.UnhandledException,
+    errors.BatchElementException,
+)
+
+
 class Test_errors:
-    def test_when_cpp_exception_thrown_then_can_be_caught_as_corresponding_python_exception(self):
-        exceptions = (
-            errors.OpenAssetIOException,
-            errors.ConfigurationException,
-            errors.InputValidationException,
-            errors.NotImplementedException,
-            errors.UnhandledException,
-            errors.BatchElementException,
-        )
+    def test_all_exceptions_list_is_exhaustive(self):
+        for _, cls in inspect.getmembers(errors, inspect.isclass):
+            if issubclass(cls, BaseException):
+                assert cls in all_exceptions
 
-        for exception in exceptions:
-            with pytest.raises(exception, match="Explosion!"):
-                _openassetio_test.throwException(exception.__name__, "Explosion!")
-
-    def test_when_cpp_exception_thrown_then_can_be_caught_as_OpenAssetIOException(self):
-        exceptions = (
-            errors.ConfigurationException,
-            errors.InputValidationException,
-            errors.NotImplementedException,
-            errors.UnhandledException,
-            errors.BatchElementException,
-        )
+    @pytest.mark.parametrize("exception_type", all_exceptions)
+    def test_when_cpp_exception_thrown_then_can_be_caught_as_corresponding_python_exception(
+        self, exception_type
+    ):
+        with pytest.raises(exception_type, match="Explosion!"):
+            _openassetio_test.throwException(exception_type.__name__, "Explosion!")
 
-        for exception in exceptions:
-            assert _openassetio_test.isThrownExceptionCatchableAs(
-                throwExceptionName=exception.__name__,
-                catchExceptionName=errors.OpenAssetIOException.__name__,
-            )
-            with pytest.raises(errors.OpenAssetIOException, match="Explosion!"):
-                _openassetio_test.throwException(exception.__name__, "Explosion!")
+    @pytest.mark.parametrize("exception_type", all_exceptions)
+    def test_when_cpp_exception_thrown_then_can_be_caught_as_OpenAssetIOException(
+        self, exception_type
+    ):
+        assert _openassetio_test.isThrownExceptionCatchableAs(
+            throwExceptionName=exception_type.__name__,
+            catchExceptionName=errors.OpenAssetIOException.__name__,
+        )
+        with pytest.raises(errors.OpenAssetIOException, match="Explosion!"):
+            _openassetio_test.throwException(exception_type.__name__, "Explosion!")
 
     def test_when_ConfigurationException_thrown_then_can_be_caught_as_InputValidationException(
         self,
@@ -66,3 +70,95 @@ def test_when_ConfigurationException_thrown_then_can_be_caught_as_InputValidatio
         )
         with pytest.raises(errors.InputValidationException, match="Explosion!"):
             _openassetio_test.throwException(errors.ConfigurationException.__name__, "Explosion!")
+
+    @pytest.mark.parametrize("exception_type", all_exceptions)
+    def test_when_python_exception_thrown_then_can_be_caught_in_cpp(
+        self, exception_type, exception_thrower
+    ):
+        exception = make_exception(exception_type)
+
+        exception_thrower.callee.side_effect = exception
+
+        assert _openassetio_test.isPythonExceptionCatchableAs(
+            exception_thrower, exception_type.__name__
+        )
+
+    @pytest.mark.parametrize("exception_type", all_exceptions)
+    def test_when_python_exception_thrown_then_can_be_caught_in_cpp_rethrown_and_caught_in_python(
+        self, exception_type, exception_thrower
+    ):
+        exception = make_exception(exception_type)
+
+        exception_thrower.callee.side_effect = exception
+
+        with pytest.raises(exception_type) as err:
+            _openassetio_test.throwPythonExceptionCatchAsCppExceptionAndRethrow(
+                exception_thrower, exception_type.__name__
+            )
+
+        assert err.value is exception
+
+    def test_when_non_OpenAssetIO_exception_thrown_then_not_translated_to_cpp_exception(
+        self, exception_thrower
+    ):
+        # Same name, but different exception.
+        class OpenAssetIOException(Exception):
+            pass
+
+        exception = OpenAssetIOException()
+
+        exception_thrower.callee.side_effect = exception
+
+        with pytest.raises(OpenAssetIOException):
+            # Should fail to catch and propagate back.
+            _openassetio_test.isPythonExceptionCatchableAs(
+                exception_thrower, "OpenAssetIOException"
+            )
+
+
+def make_exception(exception_type):
+    if exception_type == errors.BatchElementException:
+        error = errors.BatchElementError(
+            errors.BatchElementError.ErrorCode.kInvalidEntityReference, "Explosion!"
+        )
+        exception = exception_type(123, error, "Another explosion!")
+    else:
+        exception = exception_type("Explosion!")
+
+    return exception
+
+
+class PyExceptionThrower(_openassetio_test.ExceptionThrower):
+    """
+    Subclass of pybind11-bound abstract C++ class, which is expected to
+    throw the same exception from each of its methods.
+
+    Each method corresponds to an OpenAssetIO-customized pybind11
+    override macro.
+
+    The `callee` member gives a single customisation point to configure
+    the result of calling a method (i.e. the particular exception to
+    throw), which should be done using the Mock class's `side_effect`
+    property.
+    """
+
+    def __init__(self):
+        _openassetio_test.ExceptionThrower.__init__(self)
+        self.callee = mock.Mock()
+
+    def throwFromOverride(self):
+        self.callee()
+
+    def throwFromOverridePure(self):
+        self.callee()
+
+    def throwFromOverrideName(self):
+        self.callee()
+
+    def throwFromOverrideArgs(self):
+        self.callee()
+
+
+@pytest.fixture
+def exception_thrower():
+    return PyExceptionThrower()