Repository: voxel51/fiftyone

Issue:
[FR] Validate desktop package version when launching desktop App
Suppose I am a civilian desktop App user of `fiftyone==0.11.2.1`, which I installed as follows:

```shell
pip install fiftyone==0.11.2.1
pip install 'fiftyone[desktop]'
```

Then I upgrade my FiftyOne package [as instructed](https://voxel51.com/docs/fiftyone/getting_started/install.html#upgrading-fiftyone):

```shell
pip install --upgrade fiftyone
```

If I then try to launch the App:

```shell
fiftyone quickstart --desktop
```

I see an unhandled error like so:

```
An exception has been raised by the server. Your session has been reverted to its previous state.

Traceback (most recent call last):
  File "main.py", line 272, in wrapper
    result = await func(self, *args, **kwargs)
  File "main.py", line 516, in on_message
    event = getattr(self, "on_%s" % message.pop("type"))
AttributeError: 'StateHandler' object has no attribute 'on_page'
```

The solution is to upgrade my `fiftyone-desktop` package too, but, as a civilian, I didn't know that I needed to do that and the error message above doesn't help.

Proposals:
- Should we manually verify that the desktop package has a compliant version before launching the App, and, if not, print a helpful error message telling the user what to do? And/or, is there something more automatic we can do to always keep the desktop package in sync?
- And/or, add `fiftyone-desktop` upgrade instructions to [upgrade section](https://voxel51.com/docs/fiftyone/getting_started/install.html#upgrading-fiftyone)?

Related note: 

The [install guide](https://voxel51.com/docs/fiftyone/getting_started/install.html#fiftyone-desktop-app) shows `pip install fiftyone-desktop`, but `pip install 'fiftyone[desktop]'` would probably be better because the latter will install the proper desktop version for your `fiftyone` install, while the former seems to always install the latest version of the desktop package.



Discussion:
I think we could do both. At a minimum, adding `fiftyone` as a versioned requirement should work.

And also raise an informative error when there is a mismatch.

`pip install fiftyone[desktop]` should be the preferred install command.
> I think we could do both. At a minimum, adding `fiftyone` as a versioned requirement should work.

Hmm, if we add `fiftyone` as a requirement to the [desktop package](https://github.com/voxel51/fiftyone/blob/develop/package/desktop/setup.py), then I believe `pip install --upgrade fiftyone` would result in a pip error like `fiftyone-desktop XXX requires fiftyone YYY, but you have fiftyone ZZZ which is incompatible.` The issue with this is that it's backwards from what one would prefer: "your version of `fiftyone-desktop` is now incompatible and needs upgrade" (not the other way around).

Maybe I'm missing a different variation on this which is cleaner.

> And also raise an informative error when there is a mismatch.

I put together a little function for this, I guess for insertion into the [launching the desktop App code](https://github.com/voxel51/fiftyone/blob/fafc3bdd229d66f47f5a13ac75ae01da6bdc4800/fiftyone/core/session.py#L337-L353):

```py
import pkg_resources

def import_desktop():
    """Imports the `fiftyone-desktop` package and verifies that the correct
    version is installed.
    """
    try:
        import fiftyone.desktop
    except ImportError as e:
        raise ValueError(
            "You must `pip install fiftyone[desktop]` in order to launch the "
            "desktop App"
        ) from e

    # Get `fiftyone-desktop` requirement for current `fiftyone` install
    fiftyone_dist = pkg_resources.get_distribution("fiftyone")
    requirements = fiftyone_dist.requires(extras=["desktop"])
    desktop_req = [r for r in requirements if r.name == "fiftyone-desktop"][0]

    desktop_dist = pkg_resources.get_distribution("fiftyone-desktop")

    if not desktop_req.specifier.contains(desktop_dist.version):
        raise ValueError(
            "fiftyone==%s requires fiftyone-desktop%s, but you have "
            "fiftyone-desktop==%s installed.\n"
            "Run `pip install fiftyone[desktop]` to install the proper "
            "desktop package version" % (
                fiftyone_dist.version,
                desktop_req.specifier,
                desktop_dist.version
            )
        )
```


Resolving patch:
diff --git a/fiftyone/core/session.py b/fiftyone/core/session.py
--- a/fiftyone/core/session.py
+++ b/fiftyone/core/session.py
@@ -8,6 +8,7 @@
 from collections import defaultdict
 from functools import wraps
 import logging
+import pkg_resources
 import time
 from uuid import uuid4
 import webbrowser
@@ -340,14 +341,7 @@ def __init__(
                     "Cannot open a Desktop App instance from a Colab notebook"
                 )
 
-            try:
-                import fiftyone.desktop  # pylint: disable=unused-import
-            except ImportError as e:
-                if not focn.DEV_INSTALL:
-                    raise ValueError(
-                        "You must install the 'fiftyone-desktop' package "
-                        "in order to launch a desktop App instance"
-                    ) from e
+            _import_desktop()
 
             self._app_service = fos.AppService(server_port=port)
             return
@@ -356,6 +350,9 @@ def __init__(
             self.open()
             return
 
+    
+        
+        
     def _validate(self, dataset, view, plots, config):
         if dataset is not None and not isinstance(dataset, fod.Dataset):
             raise ValueError(
@@ -1074,3 +1071,32 @@ def capture(img, width):
             )
 
     output.register_callback("fiftyone.%s" % uuid.replace("-", "_"), capture)
+
+
+def _import_desktop():
+        try:
+            import fiftyone.desktop
+        except ImportError as e:
+            raise ValueError(
+                "You must `pip install fiftyone[desktop]` in order to launch the "
+                "desktop App"
+            ) from e
+
+        # Get `fiftyone-desktop` requirement for current `fiftyone` install
+        fiftyone_dist = pkg_resources.get_distribution("fiftyone")
+        requirements = fiftyone_dist.requires(extras=["desktop"])
+        desktop_req = [r for r in requirements if r.name == "fiftyone-desktop"][0]
+
+        desktop_dist = pkg_resources.get_distribution("fiftyone-desktop")
+
+        if not desktop_req.specifier.contains(desktop_dist.version):
+            raise ValueError(
+                "fiftyone==%s requires fiftyone-desktop%s, but you have "
+                "fiftyone-desktop==%s installed.\n"
+                "Run `pip install fiftyone[desktop]` to install the proper "
+                "desktop package version" % (
+                    fiftyone_dist.version,
+                    desktop_req.specifier,
+                    desktop_dist.version
+                )
+            )


Test patch: