Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
Add new exception hierarchy
## What 

Redesign exception hierarchy to incorporate/replace `exceptions.py` in C++ with Python pseudo-bindings.

## Why

It is useful for hosts to be able to branch on specific types of exception in order to customise their mitigation. In particular, it is useful for hosts to know if an exception comes from the OpenAssetIO ecosystem.

The first step in enabling this is to add the exceptions, and use them wherever we're currently relying on standard C++/Python exceptions.

## ACs

* The following exceptions are added under an `errors` namespace / submodule

```c++
OpenAssetIOException : std::runtime_error
```
```c++
UnhandledException : OpenAssetIOException
```
```c++
InputValidationException : OpenAssetIOException
```
```c++
ConfigurationException : OpenAssetIOException
```
```c++
NotImplementedException : OpenAssetIOException
```
  These exceptions simply forward the constructor of the base class.
* Python bindings using current strategy (i.e. C++->Python translation only).
* Existing `raise` or `throw` invocations migrated to use the most appropriate exception from the above.
* Issues raised to update dependent projects if required.



Discussion / hints:

Fix patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index f41ed912e..4e313783e 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -11,6 +11,19 @@ v1.0.0-alpha.xx
   namespace will be removed in a future release.
   [#1127](https://github.com/OpenAssetIO/OpenAssetIO/issues/1127)
 
+- Removed python `exceptions` module in favour of new `errors` module.
+  [#1071](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
+
+- Moved `BatchElementError` and `BatchElementException` into the
+  `errors` namespace.
+  [#1071](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
+  [#1073](https://github.com/OpenAssetIO/OpenAssetIO/issues/1073)
+
+- Removed all subtypes of `BatchElementException`, in favour of a single
+  exception type for batch errors raised by the exception throwing
+  convenience overloads.
+  [#1073](https://github.com/OpenAssetIO/OpenAssetIO/issues/1073)
+
 ### Breaking changes
 
 - Updated `ManagerInterface` to reflect which methods are required and
@@ -55,22 +68,9 @@ v1.0.0-alpha.xx
   implementations to make use of private state for efficiency reasons.
   [#518](https://github.com/OpenAssetIO/OpenAssetIO/issues/518)
 
-- Removed python `exceptions` module in favour of new `errors` module.
-  [#1071](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
-
-- `errors.h` renamed to `errorCodes.h`
+- `errors.h` renamed to `errors/errorCodes.h`
   [#1073](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
 
-- Moved `BatchElementError` and `BatchElementException` into the
-  `errors` namespace.
-  [#1071](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
-  [#1073](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
-
-- Removed all subtypes of `BatchElementException`, in favour of a single
-  exception type for batch errors raised by the exception throwing
-  convenience overloads.
-  [#1073](https://github.com/OpenAssetIO/OpenAssetIO/issues/1073)
-
 - Removed the "Sample Asset Manager" example, which was wholly out of
   date, and has been superseded by the [standalone manager template
   repository](https://github.com/OpenAssetIO/Template-OpenAssetIO-Manager-Python).
diff --git a/src/openassetio-core/CMakeLists.txt b/src/openassetio-core/CMakeLists.txt
index b76677e6a..6ed1f6b65 100644
--- a/src/openassetio-core/CMakeLists.txt
+++ b/src/openassetio-core/CMakeLists.txt
@@ -64,6 +64,7 @@ endif ()
 target_sources(
     openassetio-core
     PRIVATE
+    src/BatchElementError.cpp
     src/Context.cpp
     src/errors/exceptionMessages.cpp
     src/hostApi/HostInterface.cpp
diff --git a/src/openassetio-core/include/openassetio/BatchElementError.hpp b/src/openassetio-core/include/openassetio/BatchElementError.hpp
new file mode 100644
index 000000000..8e9a7be30
--- /dev/null
+++ b/src/openassetio-core/include/openassetio/BatchElementError.hpp
@@ -0,0 +1,68 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2023 The Foundry Visionmongers Ltd
+#pragma once
+
+#include <openassetio/export.h>
+#include <openassetio/errors/BatchElementError.hpp>
+#include <openassetio/errors/exceptions.hpp>
+
+// Deprecated:
+//  https://github.com/OpenAssetIO/OpenAssetIO/issues/1071
+//  https://github.com/OpenAssetIO/OpenAssetIO/issues/1073
+
+namespace openassetio {
+inline namespace OPENASSETIO_CORE_ABI_VERSION {
+// 'typedef' allows attributes, 'using' does not
+/// @deprecated see @fqref{errors.BatchElementError} "errors::BatchElementError"
+// NOLINTNEXTLINE
+OPENASSETIO_CORE_DEPRECATED typedef errors::BatchElementError BatchElementError;
+
+/// @deprecated see @fqref{errors.BatchElementException}
+struct OPENASSETIO_CORE_DEPRECATED_EXPORT BatchElementException : errors::BatchElementException {
+  BatchElementException(std::size_t idx, errors::BatchElementError err)  // NOLINT
+      : errors::BatchElementException(idx, err, err.message) {}
+};
+
+/// @deprecated Removed, use @fqref{errors.BatchElementException} "errors::BatchElementException"
+struct OPENASSETIO_CORE_DEPRECATED_EXPORT UnknownBatchElementException : BatchElementException {
+  using BatchElementException::BatchElementException;
+};
+
+/// @deprecated Removed, use @fqref{errors.BatchElementException} "errors::BatchElementException"
+struct OPENASSETIO_CORE_DEPRECATED_EXPORT InvalidEntityReferenceBatchElementException
+    : BatchElementException {
+  using BatchElementException::BatchElementException;
+};
+
+/// @deprecated Removed, use @fqref{errors.BatchElementException} "errors::BatchElementException"
+
+struct OPENASSETIO_CORE_DEPRECATED_EXPORT MalformedEntityReferenceBatchElementException
+    : BatchElementException {
+  using BatchElementException::BatchElementException;
+};
+
+/// @deprecated Removed, use @fqref{errors.BatchElementException} "errors::BatchElementException"
+struct OPENASSETIO_CORE_DEPRECATED_EXPORT EntityAccessErrorBatchElementException
+    : BatchElementException {
+  using BatchElementException::BatchElementException;
+};
+
+/// @deprecated Removed, use @fqref{errors.BatchElementException} "errors::BatchElementException"
+struct OPENASSETIO_CORE_DEPRECATED_EXPORT EntityResolutionErrorBatchElementException
+    : BatchElementException {
+  using BatchElementException::BatchElementException;
+};
+
+/// @deprecated Removed, use @fqref{errors.BatchElementException} "errors::BatchElementException"
+struct OPENASSETIO_CORE_DEPRECATED_EXPORT InvalidPreflightHintBatchElementException
+    : BatchElementException {
+  using BatchElementException::BatchElementException;
+};
+
+/// @deprecated Removed, use @fqref{errors.BatchElementException} "errors::BatchElementException"
+struct OPENASSETIO_CORE_DEPRECATED_EXPORT InvalidTraitSetBatchElementException
+    : BatchElementException {
+  using BatchElementException::BatchElementException;
+};
+}  // namespace OPENASSETIO_CORE_ABI_VERSION
+}  // namespace openassetio
diff --git a/src/openassetio-core/src/BatchElementError.cpp b/src/openassetio-core/src/BatchElementError.cpp
new file mode 100644
index 000000000..e93b591ac
--- /dev/null
+++ b/src/openassetio-core/src/BatchElementError.cpp
@@ -0,0 +1,6 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2023 The Foundry Visionmongers Ltd
+#include <openassetio/BatchElementError.hpp>
+
+// This file exists soley to ensure the symbol is used to
+// ensure it is present in the library on windows.
diff --git a/src/openassetio-python/package/openassetio/__init__.py b/src/openassetio-python/package/openassetio/__init__.py
index 97d4cd811..fb585d612 100644
--- a/src/openassetio-python/package/openassetio/__init__.py
+++ b/src/openassetio-python/package/openassetio/__init__.py
@@ -83,3 +83,28 @@
 
 TraitsData = trait.TraitsData
 del trait
+
+
+from ._openassetio import errors as _errors
+
+BatchElementError = _errors.BatchElementError
+
+
+class BatchElementException(_errors.BatchElementException):
+    """
+    @deprecated See openassetio.errors.BatchElementException
+    """
+
+    def __init__(self, index, error):
+        super().__init__(index, error, error.message)
+
+
+UnknownBatchElementException = _errors.BatchElementException
+InvalidEntityReferenceBatchElementException = _errors.BatchElementException
+MalformedEntityReferenceBatchElementException = _errors.BatchElementException
+EntityAccessErrorBatchElementException = _errors.BatchElementException
+EntityResolutionErrorBatchElementException = _errors.BatchElementException
+InvalidPreflightHintBatchElementException = _errors.BatchElementException
+InvalidTraitSetBatchElementException = _errors.BatchElementException
+
+del _errors
diff --git a/src/openassetio-python/package/openassetio/exceptions.py b/src/openassetio-python/package/openassetio/exceptions.py
new file mode 100644
index 000000000..eb6201044
--- /dev/null
+++ b/src/openassetio-python/package/openassetio/exceptions.py
@@ -0,0 +1,189 @@
+#
+#   Copyright 2013-2023 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+@namespace openassetio.exceptions
+Defines exceptions used in the OpenAssetIO codebase.
+
+@deprecated See @ref fqref{openassetio.errors}
+"""
+import warnings
+
+
+from openassetio import errors as _errors
+
+OpenAssetIOException = _errors.OpenAssetIOException
+
+del _errors
+
+warnings.warn("The exceptions module has moved to openassetio.errors", DeprecationWarning)
+
+
+class UserCanceled(OpenAssetIOException):
+    """
+    Thrown by the progress mechanism to interrupt execution whenever the
+    user cancels an action (perhaps using an on-screen button).
+
+    @unstable
+    """
+
+    def __str__(self):
+        return "Operation Canceled"
+
+
+##
+# @name Manager related Exceptions
+#
+## @{
+
+
+class ManagerException(OpenAssetIOException):
+    """
+    A base class for exceptions relating to, or raised by a manager.
+
+    @unstable
+    """
+
+
+class StateError(ManagerException):
+    """
+    Thrown by managers in error situations relating to the
+    managerState object.
+
+    @unstable
+    """
+
+
+class RetryableError(ManagerException):
+    """
+    Thrown by managers in error situations that can be safely retried
+    with idempotent behavior.
+
+    @unstable
+    """
+
+
+## @}
+
+
+##
+# @name Entity related Exceptions
+#
+## @{
+
+
+class BaseEntityException(ManagerException):
+    """
+    A base Exception for any @ref entity related errors to ensure
+    consistent presentation and encapsulation of the associated @ref
+    entity_reference.
+
+    @unstable
+    """
+
+    def __init__(self, message, entityReference=None):
+        """
+        @param message str, The message of the exception.
+
+        @param entityReference @fqref{EntityReference} "EntityReference"
+        The entity reference associated with the error. This should be
+        provided wherever known, and will be printed along with the
+        message in any traceback/etc... As such, there is no need to
+        embedded the entity reference in the message when using this
+        exception type.
+        """
+        super(BaseEntityException, self).__init__(message)
+        self.ref = entityReference
+
+    def __str__(self):
+        string = OpenAssetIOException.__str__(self)
+        return "%s (%s)" % (string, self.ref)
+
+
+class InvalidEntityReference(BaseEntityException):
+    """
+    Thrown whenever an Entity-based action is performed on an
+    unrecognized @ref entity_reference.
+
+    @unstable
+    """
+
+    def __init__(self, message="Invalid Entity Reference", entityReference=None):
+        super(InvalidEntityReference, self).__init__(message, entityReference)
+
+
+class MalformedEntityReference(BaseEntityException):
+    """
+    Thrown whenever an Entity-based action is performed on a
+    malformed @ref entity_reference.
+
+    @unstable
+    """
+
+    def __init__(self, message="Malformed Entity Reference", entityReference=None):
+        super(MalformedEntityReference, self).__init__(message, entityReference)
+
+
+class EntityResolutionError(BaseEntityException):
+    """
+    Thrown during @ref entity resolution,  if the Entity is valid, but
+    has no meaningful data to be resolved, or it can't be retrieved for
+    some other reason. It is also used during version finalisation and
+    any other entity-based operations on a valid @ref entity_reference
+    that fail for some reason.
+
+    @unstable
+    """
+
+    def __init__(self, message="Error resolving entity", entityReference=None):
+        super(EntityResolutionError, self).__init__(message, entityReference)
+
+
+class BaseEntityInteractionError(BaseEntityException):
+    """
+    A base class for errors relating to entity-centric actions.
+
+    @unstable
+    """
+
+
+class PreflightError(BaseEntityInteractionError):
+    """
+    Thrown to represent some error during pre-flight that isn't due to
+    any specific of the @ref entity_reference itself.
+
+    @unstable
+    """
+
+
+class RegistrationError(BaseEntityInteractionError):
+    """
+    Thrown to represent some error during registration that isn't due to
+    any specific of the @ref entity_reference itself.
+
+    @unstable
+    """
+
+
+## @}
+
+
+class PluginError(OpenAssetIOException):
+    """
+    Thrown by the plugin system in relation to errors encountered
+    during the loading/initialization of plugins.
+
+    @unstable
+    """


Test patch:
diff --git a/src/openassetio-core/tests/deprecationsTest.cpp b/src/openassetio-core/tests/deprecationsTest.cpp
index e9aa0f9f3..17f1f9f5d 100644
--- a/src/openassetio-core/tests/deprecationsTest.cpp
+++ b/src/openassetio-core/tests/deprecationsTest.cpp
@@ -4,9 +4,6 @@
 
 #include <catch2/catch.hpp>
 
-#include <openassetio/TraitsData.hpp>
-#include <openassetio/trait/TraitsData.hpp>
-
 // Disable deprecation warning that triggers -Werror, failing the build.
 #if defined(__clang__)
 #pragma clang diagnostic ignored "-Wdeprecated-declarations"
@@ -14,6 +11,12 @@
 #pragma GCC diagnostic ignored "-Wdeprecated-declarations"
 #endif
 
+#include <openassetio/TraitsData.hpp>
+#include <openassetio/trait/TraitsData.hpp>
+
+#include <openassetio/BatchElementError.hpp>
+#include <openassetio/errors/BatchElementError.hpp>
+
 // Tests for deprecated functionality.
 // All cases must have links to the relevant GitHub issues.
 
@@ -21,3 +24,33 @@
 SCENARIO("TraitsData still accessible in top level namespace") {
   STATIC_REQUIRE(std::is_same_v<openassetio::TraitsData, openassetio::trait::TraitsData>);
 }
+
+// https://github.com/OpenAssetIO/OpenAssetIO/issues/1071
+// https://github.com/OpenAssetIO/OpenAssetIO/issues/1073
+SCENARIO("BatchElementError and exceptions still accessible in top level namespace") {
+  STATIC_REQUIRE(
+      std::is_same_v<openassetio::BatchElementError, openassetio::errors::BatchElementError>);
+}
+SCENARIO("BatchElementExceptions still accessible in top level namespace") {
+  const openassetio::BatchElementError anError{
+      openassetio::BatchElementError::ErrorCode::kEntityAccessError, "msg"};
+  const openassetio::BatchElementException anException{0, anError};
+  CHECK(anException.what() == std::string{"msg"});
+
+  STATIC_REQUIRE(std::is_base_of_v<openassetio::errors::BatchElementException,
+                                   openassetio::BatchElementException>);
+  STATIC_REQUIRE(std::is_base_of_v<openassetio::BatchElementException,
+                                   openassetio::UnknownBatchElementException>);
+  STATIC_REQUIRE(std::is_base_of_v<openassetio::BatchElementException,
+                                   openassetio::InvalidEntityReferenceBatchElementException>);
+  STATIC_REQUIRE(std::is_base_of_v<openassetio::BatchElementException,
+                                   openassetio::MalformedEntityReferenceBatchElementException>);
+  STATIC_REQUIRE(std::is_base_of_v<openassetio::BatchElementException,
+                                   openassetio::EntityAccessErrorBatchElementException>);
+  STATIC_REQUIRE(std::is_base_of_v<openassetio::BatchElementException,
+                                   openassetio::EntityResolutionErrorBatchElementException>);
+  STATIC_REQUIRE(std::is_base_of_v<openassetio::BatchElementException,
+                                   openassetio::InvalidPreflightHintBatchElementException>);
+  STATIC_REQUIRE(std::is_base_of_v<openassetio::BatchElementException,
+                                   openassetio::InvalidTraitSetBatchElementException>);
+}
diff --git a/src/openassetio-python/tests/package/test_deprecations.py b/src/openassetio-python/tests/package/test_deprecations.py
index 1061d4f62..c78471864 100644
--- a/src/openassetio-python/tests/package/test_deprecations.py
+++ b/src/openassetio-python/tests/package/test_deprecations.py
@@ -20,9 +20,13 @@
 docstrings.
 """
 
+# pylint: disable=invalid-name
 # pylint: disable=missing-function-docstring,import-outside-toplevel
 
 
+import pytest
+
+
 def test_renamed_kField_constants():
     """
     https://github.com/OpenAssetIO/OpenAssetIO/issues/998
@@ -44,3 +48,57 @@ def test_top_level_TraitsData_import():
     from openassetio import trait, TraitsData
 
     assert TraitsData is trait.TraitsData
+
+
+def test_top_level_exceptions_import():
+    """
+    https://github.com/OpenAssetIO/OpenAssetIO/issues/1071
+    https://github.com/OpenAssetIO/OpenAssetIO/issues/1073
+    """
+    from openassetio import (
+        BatchElementError,
+        BatchElementException,
+        UnknownBatchElementException,
+        InvalidEntityReferenceBatchElementException,
+        MalformedEntityReferenceBatchElementException,
+        EntityAccessErrorBatchElementException,
+        EntityResolutionErrorBatchElementException,
+        InvalidPreflightHintBatchElementException,
+        InvalidTraitSetBatchElementException,
+        errors,
+    )
+
+    assert BatchElementError is errors.BatchElementError
+
+    assert issubclass(BatchElementException, errors.BatchElementException)
+    expected_message = "a message"
+    an_error = BatchElementError(BatchElementError.ErrorCode.kEntityAccessError, expected_message)
+    an_exception = BatchElementException(0, an_error)
+    assert str(an_exception) == expected_message
+
+    assert UnknownBatchElementException is errors.BatchElementException
+    assert InvalidEntityReferenceBatchElementException is errors.BatchElementException
+    assert MalformedEntityReferenceBatchElementException is errors.BatchElementException
+    assert EntityAccessErrorBatchElementException is errors.BatchElementException
+    assert EntityResolutionErrorBatchElementException is errors.BatchElementException
+    assert InvalidPreflightHintBatchElementException is errors.BatchElementException
+    assert InvalidTraitSetBatchElementException is errors.BatchElementException
+
+    with pytest.warns(
+        DeprecationWarning, match="The exceptions module has moved to openassetio.errors"
+    ):
+        from openassetio import exceptions
+
+    assert exceptions.OpenAssetIOException is errors.OpenAssetIOException
+    assert exceptions.UserCanceled
+    assert exceptions.ManagerException
+    assert exceptions.StateError
+    assert exceptions.RetryableError
+    assert exceptions.BaseEntityException
+    assert exceptions.InvalidEntityReference
+    assert exceptions.MalformedEntityReference
+    assert exceptions.EntityResolutionError
+    assert exceptions.BaseEntityInteractionError
+    assert exceptions.PreflightError
+    assert exceptions.RegistrationError
+    assert exceptions.PluginError