Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
Relax `castToPyObject`/`castFromPyObject` null checks
## What

Convert a `shared_ptr` holding a `nullptr` to `None` when passed to `castToPyObject`, and vice-versa for `castFromPyObject`.

## Why

In real-world use cases, it may well be useful to get a `None` value from an uninitialised `shared_ptr`.  

E.g. testing whether a `ManagerPtr` has been initialised or not:

Assume `getMyManager` is a C++ function with Python binding.  

In C++ `if (getMyManager())` can be used (`shared_ptr`s are implicitly convertible to `bool`).  

We might naturally assume `if getMyManager() is not None:` could be used in Python.  However, `castToPyObject` will throw an exception on a `nullptr`, rather than convert to `None`.

Note that pybind11's default behaviour is to convert `nullptr` smart pointers to `None` - the exception is thrown because we explicitly added a check.

## Acceptance Criteria

- No exception thrown when converting `nullptr`/`None`.



Discussion / hints:
A patch of my experiment changing to returning `None` rather than throwing an exception:

```
index 8dc7c929..7d7af0d5 100644
--- a/src/openassetio-python/bridge/src/python/converter.cpp
+++ b/src/openassetio-python/bridge/src/python/converter.cpp
@@ -35,12 +35,6 @@ PyObject* castToPyObject(const T& objectPtr) {
   // scope
   const py::error_scope previousErrorState;
 
-  if (objectPtr == nullptr) {
-    throw std::invalid_argument(
-        "Attempting to cast a nullptr objectPtr in "
-        "openassetio::python::converter::castToPyObject");
-  }
-
   py::object pybindObj = py::cast(objectPtr);
 
   // Check to see if the py::cast has spawned a python error
diff --git a/src/openassetio-python/tests/bridge/python/test_converter.cpp b/src/openassetio-python/tests/bridge/python/test_converter.cpp
index 168ea935..aae4f6da 100644
--- a/src/openassetio-python/tests/bridge/python/test_converter.cpp
+++ b/src/openassetio-python/tests/bridge/python/test_converter.cpp
@@ -271,12 +271,10 @@ TEMPLATE_LIST_TEST_CASE("Appropriate classes have castFromPyObject functions", "
 }
 
 TEMPLATE_LIST_TEST_CASE("Appropriate classes have castToPyObject functions", "", CastableClasses) {
-  // These tests check that the nullptr exception works, but also
+  // These tests check that nullptrs are converted to None, but also
   // serve to verify that the functions exist for all expected types.
   const typename TestType::Ptr empty = nullptr;
-  REQUIRE_THROWS_WITH(openassetio::python::converter::castToPyObject(empty),
-                      std::string("Attempting to cast a nullptr objectPtr in "
-                                  "openassetio::python::converter::castToPyObject"));
+  CHECK(openassetio::python::converter::castToPyObject(empty) == Py_None);
 }
 }  // namespace OPENASSETIO_CORE_ABI_VERSION
 }  // namespace openassetio
```
We should also do the same going the other way.

Fix patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 48003285a..be7ea1e6d 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -22,6 +22,10 @@ v1.0.0-alpha.xx
   details.
   [#980](https://github.com/OpenAssetIO/OpenAssetIO/issues/980)
 
+- Updated `castToPyObject` to convert a `nullptr` input to a Python
+  `None`, rather than throwing an exception.
+  [#988](https://github.com/OpenAssetIO/OpenAssetIO/issues/988)
+
 ### Improvements
 
 - Added paged implementations of `getWithRelationship` and
@@ -42,7 +46,6 @@ v1.0.0-alpha.xx
   `ManagerInterface` to the `openassetio.manager.test` harness.
   [#914](https://github.com/OpenAssetIO/OpenAssetIO/issues/914)
 
-
 - Added `requireEntityReferenceFixture` and
   `requireEntityReferencesFixture` utility methods for cases written for
   the `openassetio.test.manager` (aka `apiComplianceSuite`) test
diff --git a/src/openassetio-python/bridge/src/python/converter.cpp b/src/openassetio-python/bridge/src/python/converter.cpp
index 8dc7c929b..7d7af0d53 100644
--- a/src/openassetio-python/bridge/src/python/converter.cpp
+++ b/src/openassetio-python/bridge/src/python/converter.cpp
@@ -35,12 +35,6 @@ PyObject* castToPyObject(const T& objectPtr) {
   // scope
   const py::error_scope previousErrorState;
 
-  if (objectPtr == nullptr) {
-    throw std::invalid_argument(
-        "Attempting to cast a nullptr objectPtr in "
-        "openassetio::python::converter::castToPyObject");
-  }
-
   py::object pybindObj = py::cast(objectPtr);
 
   // Check to see if the py::cast has spawned a python error


Test patch:
diff --git a/src/openassetio-python/tests/bridge/python/test_converter.cpp b/src/openassetio-python/tests/bridge/python/test_converter.cpp
index 168ea9354..4a72da6fc 100644
--- a/src/openassetio-python/tests/bridge/python/test_converter.cpp
+++ b/src/openassetio-python/tests/bridge/python/test_converter.cpp
@@ -156,7 +156,11 @@ SCENARIO("Casting to a C++ object binds object lifetime") {
   }
 }
 
-SCENARIO("Attempting to cast to an unregistered type") {
+SCENARIO("Attempting to cast from an incorrect Python type") {
+  using openassetio::hostApi::Manager;
+  using openassetio::hostApi::ManagerPtr;
+  namespace converter = openassetio::python::converter;
+
   GIVEN("an invalid for casting Python object") {
     // Use pybind to conveniently create a CPython object with a ref count of 1.
     const py::object pyClass = py::module_::import("decimal").attr("Decimal");
@@ -173,14 +177,18 @@ SCENARIO("Attempting to cast to an unregistered type") {
      * mode for details)"
      */
     WHEN("object is converted to a C++ object") {
-      REQUIRE_THROWS_WITH(
-          openassetio::python::converter::castFromPyObject<openassetio::hostApi::Manager>(
-              pyDecimal),
-          Catch::Matchers::StartsWith("Unable to cast Python instance"));
+      REQUIRE_THROWS_WITH(converter::castFromPyObject<Manager>(pyDecimal),
+                          Catch::Matchers::StartsWith("Unable to cast Python instance"));
     }
 
     Py_DECREF(pyDecimal);
   }
+
+  WHEN("None is converted to a C++ object") {
+    const ManagerPtr manager = converter::castFromPyObject<Manager>(Py_None);
+
+    THEN("C++ object pointer is null") { CHECK(manager.get() == nullptr); }
+  }
 }
 
 // NB: This test is excluded by default and is executed by explicitly
@@ -271,12 +279,10 @@ TEMPLATE_LIST_TEST_CASE("Appropriate classes have castFromPyObject functions", "
 }
 
 TEMPLATE_LIST_TEST_CASE("Appropriate classes have castToPyObject functions", "", CastableClasses) {
-  // These tests check that the nullptr exception works, but also
+  // These tests check that nullptrs are converted to None, but also
   // serve to verify that the functions exist for all expected types.
   const typename TestType::Ptr empty = nullptr;
-  REQUIRE_THROWS_WITH(openassetio::python::converter::castToPyObject(empty),
-                      std::string("Attempting to cast a nullptr objectPtr in "
-                                  "openassetio::python::converter::castToPyObject"));
+  CHECK(openassetio::python::converter::castToPyObject(empty) == Py_None);
 }
 }  // namespace OPENASSETIO_CORE_ABI_VERSION
 }  // namespace openassetio