Repository: PennyLaneAI/pennylane

Issue:
Speed up PennyLane load time
### Feature details

I have noticed that PennyLane import time is growing longer and longer and did a quick profiling session following [this guide](https://pythondev.readthedocs.io/startup_time.html). The results look like this:

![image](https://user-images.githubusercontent.com/60615188/144246164-b782e7e9-fd12-4b70-ad6d-9a4ab147ab6d.png)

So, over 60% of the time is spent on importing qchem and most of that is attributable to openfermion. Now of course you can say that I should approach the openfermion people and tell them to speed up their load time, but clearly there are many programs that use pennylane, but don't need qchem and openfermion and in that case, even if those are installed, it would be nice if pennylane would start up fast.

Can qchem maybe be imported on demand?

### Implementation

_No response_

### How important would you say this feature is?

1: Not important. Would be nice to have.

### Additional information

_No response_


Discussion:
Nice catch @cvjjm! Speeding up the import time would be a huge quality-of-life improvement.

Currently, we dynamically import `qchem` here, if the entry point exists:

https://github.com/PennyLaneAI/pennylane/blob/854d4b4c5045d69e3def921c8f6ca3519afac193/pennylane/__init__.py#L140-L142

but I'm sure we can somehow make this a 'lazy' import that is only loaded when first called.
@cvjjm I'm suddenly a bit confused. I can't recreate this behaviour locally; when I `import pennylane as qml`, I don't have OpenFermion automatically imported.

It seems we are already taking care of this in the `qchem` package:

https://github.com/PennyLaneAI/pennylane/blob/9687c130b7c4f758238859f2fe0dbb811a07825a/qchem/pennylane_qchem/qchem/openfermion.py#L18-L24

Are you using an older version of qchem?

Resolving patch:
diff --git a/pennylane/__init__.py b/pennylane/__init__.py
--- a/pennylane/__init__.py
+++ b/pennylane/__init__.py
@@ -16,6 +16,7 @@
 PennyLane can be directly imported.
 """
 from importlib import reload
+import types
 import pkg_resources
 
 import numpy as _np
@@ -110,38 +111,6 @@ def refresh_devices():
 plugin_devices = _get_device_entrypoints()
 
 
-# get chemistry plugin
-class NestedAttrError:
-    """This class mocks out the qchem module in case
-    it is not installed. Any attempt to print an instance
-    of this class, or to access an attribute of this class,
-    results in an import error, directing the user to the installation
-    instructions for PennyLane Qchem"""
-
-    error_msg = (
-        "PennyLane-QChem not installed. \n\nTo access the qchem "
-        "module, you can install PennyLane-QChem via pip:"
-        "\n\npip install pennylane-qchem"
-        "\n\nFor more details, see the quantum chemistry documentation:"
-        "\nhttps://pennylane.readthedocs.io/en/stable/introduction/chemistry.html"
-    )
-
-    def __str__(self):
-        raise ImportError(self.error_msg) from None
-
-    def __getattr__(self, name):
-        raise ImportError(self.error_msg) from None
-
-    __repr__ = __str__
-
-
-qchem = NestedAttrError()
-
-for entry in pkg_resources.iter_entry_points("pennylane.qchem"):
-    if entry.name == "OpenFermion":
-        qchem = entry.load()
-
-
 def device(name, *args, **kwargs):
     r"""device(name, wires=1, *args, **kwargs)
     Load a :class:`~.Device` and return the instance.
@@ -315,8 +284,8 @@ def run_cnot():
 
         if Version(version()) not in Spec(plugin_device_class.pennylane_requires):
             raise DeviceError(
-                f"The {name} plugin requires PennyLane versions {plugin_device_class.pennylane_requires}, however PennyLane "
-                "version {__version__} is installed."
+                f"The {name} plugin requires PennyLane versions {plugin_device_class.pennylane_requires}, "
+                f"however PennyLane version {__version__} is installed."
             )
 
         # Construct the device
@@ -338,3 +307,45 @@ def run_cnot():
 def version():
     """Returns the PennyLane version number."""
     return __version__
+
+
+# add everything as long as it's not a module and not prefixed with _
+_all = sorted(
+    [
+        name
+        for name, function in globals().items()
+        if not (name.startswith("_") or isinstance(function, types.ModuleType))
+    ]
+)
+
+
+_qchem = None
+
+
+def __getattr__(name):
+    """Ensure that the qchem module is imported lazily"""
+    if name == "qchem":
+        global _qchem  # pylint: disable=global-statement
+
+        if _qchem is None:
+
+            for entry in pkg_resources.iter_entry_points("pennylane.qchem"):
+                if entry.name == "OpenFermion":
+                    _qchem = entry.load()
+
+            if _qchem is None:
+                raise ImportError(
+                    "PennyLane-QChem not installed. \n\nTo access the qchem "
+                    "module, you can install PennyLane-QChem via pip:"
+                    "\n\npip install pennylane-qchem"
+                    "\n\nFor more details, see the quantum chemistry documentation:"
+                    "\nhttps://pennylane.readthedocs.io/en/stable/introduction/chemistry.html"
+                )
+
+        return _qchem
+
+    raise AttributeError(f"module {__name__} has no attribute {name}")
+
+
+def __dir__():  # pragma: no cover
+    return _all + ["qchem"]


Test patch: