Repository: cthoyt/class-resolver

Issue:
Conflict between PyTorch and PyTorch Lightning
Thanks for the package! It looks like there is a conflict between PyTorch and PyTorch Lightning when making use of `class_resolver.contrib.torch`. The following can re-produce the issue:

```python
import torch
import pytorch_lightning
from class_resolver.contrib.torch import optimizer_resolver
```
```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/rusty1s/miniconda3/lib/python3.7/site-packages/class_resolver/contrib/torch.py", line 71, in <module>
    suffix="LR",
  File "/Users/rusty1s/miniconda3/lib/python3.7/site-packages/class_resolver/api.py", line 159, in from_subclasses
    **kwargs,
  File "/Users/rusty1s/miniconda3/lib/python3.7/site-packages/class_resolver/api.py", line 119, in __init__
    suffix=suffix,
  File "/Users/rusty1s/miniconda3/lib/python3.7/site-packages/class_resolver/base.py", line 106, in __init__
    self.register(element)
  File "/Users/rusty1s/miniconda3/lib/python3.7/site-packages/class_resolver/base.py", line 153, in register
    raise RegistrationNameConflict(self, key, element, label="name")
class_resolver.base.RegistrationNameConflict: Conflict on registration of name exponential:
Existing: <class 'pytorch_lightning.tuner.lr_finder._ExponentialLR'>
Proposed: <class 'torch.optim.lr_scheduler.ExponentialLR'>
```

Comments:

Patch:
diff --git a/src/class_resolver/api.py b/src/class_resolver/api.py
index b3b3b49..a4d25aa 100644
--- a/src/class_resolver/api.py
+++ b/src/class_resolver/api.py
@@ -136,6 +136,7 @@ class ClassResolver(BaseResolver[Type[X], X]):
         *,
         skip: Optional[Collection[Type[X]]] = None,
         exclude_private: bool = True,
+        exclude_external: bool = True,
         **kwargs,
     ) -> "ClassResolver":
         """Make a resolver from the subclasses of a given class.
@@ -145,6 +146,8 @@ class ClassResolver(BaseResolver[Type[X], X]):
         :param exclude_private: If true, will skip any class that comes from a module
             starting with an underscore (i.e., a private module). This is typically
             done when having shadow duplicate classes implemented in C
+        :param exclude_external: If true, will exclude any class that does not originate
+            from the same package as the base class.
         :param kwargs: remaining keyword arguments to pass to :func:`Resolver.__init__`
         :return: A resolver instance
         """
@@ -152,7 +155,9 @@ class ClassResolver(BaseResolver[Type[X], X]):
         return cls(
             {
                 subcls
-                for subcls in get_subclasses(base, exclude_private=exclude_private)
+                for subcls in get_subclasses(
+                    base, exclude_private=exclude_private, exclude_external=exclude_external
+                )
                 if subcls not in skip
             },
             base=base,
diff --git a/src/class_resolver/utils.py b/src/class_resolver/utils.py
index f357954..f734ea1 100644
--- a/src/class_resolver/utils.py
+++ b/src/class_resolver/utils.py
@@ -35,6 +35,7 @@ __all__ = [
     "normalize_string",
     "upgrade_to_sequence",
     "make_callback",
+    "same_module",
 ]
 
 X = TypeVar("X")
@@ -54,22 +55,38 @@ OptionalKwargs = Optional[Mapping[str, Any]]
 OneOrSequence = Union[X, Sequence[X]]
 
 
-def get_subclasses(cls: Type[X], exclude_private: bool = True) -> Iterable[Type[X]]:
+def get_subclasses(
+    cls: Type[X],
+    exclude_private: bool = True,
+    exclude_external: bool = True,
+) -> Iterable[Type[X]]:
     """Get all subclasses.
 
     :param cls: The ancestor class
     :param exclude_private: If true, will skip any class that comes from a module
         starting with an underscore (i.e., a private module). This is typically
         done when having shadow duplicate classes implemented in C
+    :param exclude_external: If true, will exclude any class that does not originate
+        from the same package as the base class.
     :yields: Descendant classes of the ancestor class
     """
     for subclass in cls.__subclasses__():
         yield from get_subclasses(subclass)
-        if exclude_private and any(part.startswith("_") for part in subclass.__module__.split(".")):
+        if exclude_private:
+            if any(part.startswith("_") for part in subclass.__module__.split(".")):
+                continue
+            if subclass.__name__.startswith("_"):
+                continue
+        if exclude_external and not same_module(cls, subclass):
             continue
         yield subclass
 
 
+def same_module(cls1: type, cls2: type) -> bool:
+    """Return if two classes come from the same module via the ``__module__`` attribute."""
+    return cls1.__module__.split(".")[0] == cls2.__module__.split(".")[0]
+
+
 def normalize_string(s: str, *, suffix: Optional[str] = None) -> str:
     """Normalize a string for lookup."""
     s = s.lower().replace("-", "").replace("_", "").replace(" ", "")


Test patch:
diff --git a/tests/_private_extras.py b/tests/_private_extras.py
new file mode 100644
index 0000000..861e181
--- /dev/null
+++ b/tests/_private_extras.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+
+"""This Module helps test private extras."""
+
+
+class PrivateDict(dict):
+    """A priviate dictionary."""
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..7dc39bd
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+
+"""Test utilities."""
+
+import enum
+import unittest
+from collections import Counter, defaultdict
+
+from class_resolver.utils import get_subclasses, same_module
+from tests._private_extras import PrivateDict
+
+
+class TestUtilities(unittest.TestCase):
+    """Test utilities."""
+
+    def test_same_module(self):
+        """Test getting subclasses."""
+        self.assertFalse(same_module(Counter, dict))
+        self.assertTrue(same_module(Counter, defaultdict))
+
+    def test_get_subclasses(self):
+        """Test getting subclasses."""
+        self.assertTrue(issubclass(PrivateDict, dict))
+
+        self.assertNotIn(Counter, set(get_subclasses(dict, exclude_external=True)))
+        self.assertIn(Counter, set(get_subclasses(dict, exclude_external=False)))
+
+        self.assertIn(
+            PrivateDict, set(get_subclasses(dict, exclude_external=False, exclude_private=False))
+        )
+        self.assertNotIn(
+            PrivateDict, set(get_subclasses(dict, exclude_external=False, exclude_private=True))
+        )
+        self.assertNotIn(
+            PrivateDict, set(get_subclasses(dict, exclude_external=True, exclude_private=False))
+        )
+        self.assertNotIn(
+            PrivateDict, set(get_subclasses(dict, exclude_external=True, exclude_private=True))
+        )
+
+        self.assertIn(
+            enum._EnumDict, set(get_subclasses(dict, exclude_external=False, exclude_private=False))
+        )
+        self.assertNotIn(
+            enum._EnumDict, set(get_subclasses(dict, exclude_external=False, exclude_private=True))
+        )
+        self.assertNotIn(
+            enum._EnumDict, set(get_subclasses(dict, exclude_external=True, exclude_private=False))
+        )
+        self.assertNotIn(
+            enum._EnumDict, set(get_subclasses(dict, exclude_external=True, exclude_private=True))
+        )