Repository: rdkit/rdkit — pull request #7664 (cpp)
Title: Fixes #6757

Simple change to make sure we have called `assignStereochemistry()` before doing the work in `numAtomStereoCenters()` and `numUnspecifiedAtomStereoCenters()`

Resolved issues:
title: rdMolDescriptors.CalcNumAtomStereoCenters fails on Sanitized Molecule
body: **Describe the bug**
`rdMolDescriptors.CalcNumAtomStereoCenters` fails to find stereo centers if a molecule is sanitized (twice).
I had assumed this was deuterium-related, but it appears to affect other stereo centers as well (see https://github.com/rdkit/rdkit/discussions/6657).

**To Reproduce**
```python
from rdkit import Chem
from rdkit.Chem import rdMolDescriptors

smi = "[2H][C@@](C)(N)C(=O)O"
mol1 = Chem.MolFromSmiles(smi)
cip_count1 = sum([atom.HasProp("_CIPCode") for atom in mol1.GetAtoms()])
stereocenters1 = rdMolDescriptors.CalcNumAtomStereoCenters(mol1)


mol2 = Chem.Mol(mol1)
Chem.SanitizeMol(mol2)
cip_count2 = sum([atom.HasProp("_CIPCode") for atom in mol2.GetAtoms()])
try:
    stereocenters2 = rdMolDescriptors.CalcNumAtomStereoCenters(mol2)
except: # ValueError: numStereoCenters called without stereo being assigned
    stereocenters2 = 0

assert cip_count1 == cip_count2
assert stereocenters1 == stereocenters2 # Fails. 1 != 0
```

**Expected behavior**
One stereo center should be detected for both molecules. 
The properties  `_CIPCode` and `_ChiralityPossible` are still correct so I've been counting atom CIPCodes as a workaround.


**Configuration:**
 - RDKit version: 2022.9.5 (linked discussion is on 2023.03.3)
 - OS: Ubuntu 22.04.3
 - Python version: 3.9
 - Are you using conda? No
 - How did you install the RDKit? rdkit-pypi via Poetry


Hints:

Fix patch:
diff --git a/Code/GraphMol/Chirality.cpp b/Code/GraphMol/Chirality.cpp
index e5a40ddee69..d4c1f35e931 100644
--- a/Code/GraphMol/Chirality.cpp
+++ b/Code/GraphMol/Chirality.cpp
@@ -2771,6 +2771,10 @@ void assignStereochemistry(ROMol &mol, bool cleanIt, bool force,
     return;
   }
 
+  if (mol.needsUpdatePropertyCache()) {
+    mol.updatePropertyCache(false);
+  }
+
   if (!Chirality::getUseLegacyStereoPerception()) {
     Chirality::stereoPerception(mol, cleanIt, flagPossibleStereoCenters);
   } else {
diff --git a/Code/GraphMol/Descriptors/Lipinski.cpp b/Code/GraphMol/Descriptors/Lipinski.cpp
index e0b044aed34..d80727f8184 100644
--- a/Code/GraphMol/Descriptors/Lipinski.cpp
+++ b/Code/GraphMol/Descriptors/Lipinski.cpp
@@ -506,36 +506,46 @@ bool hasStereoAssigned(const ROMol &mol) {
   return mol.hasProp(common_properties::_StereochemDone);
 }
 }  // namespace
-const std::string NumAtomStereoCentersVersion = "1.0.0";
+const std::string NumAtomStereoCentersVersion = "1.0.1";
 unsigned int numAtomStereoCenters(const ROMol &mol) {
+  std::unique_ptr<ROMol> tmol;
+  const ROMol *mptr = &mol;
   if (!hasStereoAssigned(mol)) {
-    throw ValueErrorException(
-        "numStereoCenters called without stereo being assigned");
+    tmol.reset(new ROMol(mol));
+    constexpr bool cleanIt = true;
+    constexpr bool force = true;
+    constexpr bool flagPossible = true;
+    MolOps::assignStereochemistry(*tmol, cleanIt, force, flagPossible);
+    mptr = tmol.get();
   }
 
   unsigned int res = 0;
-  for (ROMol::ConstAtomIterator atom = mol.beginAtoms(); atom != mol.endAtoms();
-       ++atom) {
-    if ((*atom)->hasProp(common_properties::_ChiralityPossible)) {
-      res++;
+  for (const auto &atom : mptr->atoms()) {
+    if (atom->hasProp(common_properties::_ChiralityPossible)) {
+      ++res;
     }
   }
   return res;
 }
 
-const std::string NumUnspecifiedAtomStereoCentersVersion = "1.0.0";
+const std::string NumUnspecifiedAtomStereoCentersVersion = "1.0.1";
 unsigned int numUnspecifiedAtomStereoCenters(const ROMol &mol) {
+  std::unique_ptr<ROMol> tmol;
+  const ROMol *mptr = &mol;
   if (!hasStereoAssigned(mol)) {
-    throw ValueErrorException(
-        "numUnspecifiedStereoCenters called without stereo being assigned");
+    tmol.reset(new ROMol(mol));
+    constexpr bool cleanIt = true;
+    constexpr bool force = true;
+    constexpr bool flagPossible = true;
+    MolOps::assignStereochemistry(*tmol, cleanIt, force, flagPossible);
+    mptr = tmol.get();
   }
 
   unsigned int res = 0;
-  for (ROMol::ConstAtomIterator atom = mol.beginAtoms(); atom != mol.endAtoms();
-       ++atom) {
-    if ((*atom)->hasProp(common_properties::_ChiralityPossible) &&
-        (*atom)->getChiralTag() == Atom::CHI_UNSPECIFIED) {
-      res++;
+  for (const auto &atom : mptr->atoms()) {
+    if (atom->hasProp(common_properties::_ChiralityPossible) &&
+        atom->getChiralTag() == Atom::CHI_UNSPECIFIED) {
+      ++res;
     }
   }
   return res;


Test patch:
diff --git a/Code/GraphMol/Descriptors/catch_tests.cpp b/Code/GraphMol/Descriptors/catch_tests.cpp
index bcac0b2dd7c..594fddb1fca 100644
--- a/Code/GraphMol/Descriptors/catch_tests.cpp
+++ b/Code/GraphMol/Descriptors/catch_tests.cpp
@@ -618,4 +618,22 @@ TEST_CASE("Github #7364: BCUT descriptors failing for moleucles with Hs") {
     CHECK(ref.size() == val.size());
     CHECK(ref == val);
   }
+}
+
+TEST_CASE(
+    "Github #6757: numAtomStereoCenters fails if molecule is sanitized a second time") {
+  SECTION("as reported") {
+    auto m = "C[C@H](F)Cl"_smiles;
+    REQUIRE(m);
+    CHECK(Descriptors::numAtomStereoCenters(*m) == 1);
+    CHECK(Descriptors::numUnspecifiedAtomStereoCenters(*m) == 0);
+    MolOps::sanitizeMol(*m);
+    CHECK(Descriptors::numAtomStereoCenters(*m) == 1);
+  }
+  SECTION("expanded") {
+    auto m = "C[C@H](F)C(O)Cl"_smiles;
+    REQUIRE(m);
+    CHECK(Descriptors::numAtomStereoCenters(*m) == 2);
+    CHECK(Descriptors::numUnspecifiedAtomStereoCenters(*m) == 1);
+  }
 }
\ No newline at end of file
diff --git a/Code/GraphMol/Descriptors/test.cpp b/Code/GraphMol/Descriptors/test.cpp
index 361a77f083f..18de6fac347 100644
--- a/Code/GraphMol/Descriptors/test.cpp
+++ b/Code/GraphMol/Descriptors/test.cpp
@@ -1993,16 +1993,14 @@ void testStereoCounting() {
   names.emplace_back("NumUnspecifiedAtomStereoCenters");
   Properties prop(names);
 
-  try {
-    prop.computeProperties(*m);
-    TEST_ASSERT(0);  // didn't catch exception
-  } catch (ValueErrorException &) {
-    BOOST_LOG(rdErrorLog) << "---Caught stereo value error---" << std::endl;
-  }
+  // the stereo perception is done automatically:
+  std::vector<double> res = prop.computeProperties(*m);
+  TEST_ASSERT(res[0] == 1);
+  TEST_ASSERT(res[1] == 1);
 
   delete m;
   m = SmilesToMol("NC(C)(F)C(=O)O", sanitize);
-  std::vector<double> res = prop.computeProperties(*m);
+  res = prop.computeProperties(*m);
   TEST_ASSERT(res[0] == 1);
   TEST_ASSERT(res[1] == 1);
   delete m;