Repository: spyder-ide/spyder

Issue:
Create conda-based standalone application
Making conda-based standalone applications for macOS, Windows, and Linux is desirable to facilitate installing/uninstalling third-party plugins.


Discussion:
@jaimergp, given your work on napari/napari, would you be willing to discuss with me some of the issues you ran into and how you resolved them?

Also pinging @dalthviz.
Sure! Let me know what you need to know :)

The napari docs have some info:

- https://napari.org/dev/developers/packaging.html
- NAP-2: https://napari.org/dev/naps/2-conda-based-packaging.html#

That's probably a good start. 
@jaimergp, thanks for being willing to share.

The Spyder project currently creates a standalone application bundle for macOS using `py2app` (I'm the principle maintainer). However, I think Spyder and Napari developers share similar motivations for building standalone applications based on `conda`.

I find two types of release artifacts for macOS: pkg and zip. The package installer (pkg) installs a local conda environment and napari-\<ver\>.app. This application bundle is a wrapper for a shell script that references the local conda environment. The zip artifact is just a disk image (dmg) containing napari.app and appears to be standalone (python and necessary packages are contained inside the application bundle).
- Can you explain why there are two different application types? FYI, the standalone application bundle crashes on launch.
<details><summary>traceback</summary>

```
Traceback (most recent call last):
  File "napari.app/Contents/Resources/app/napari/__main__.py", line 453, in <module>
    sys.exit(main())
  File "napari.app/Contents/Resources/app/napari/__main__.py", line 449, in main
    _run()
  File "napari.app/Contents/Resources/app/napari/__main__.py", line 214, in _run
    from napari import Viewer, run
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "<frozen importlib._bootstrap>", line 1055, in _handle_fromlist
  File "napari.app/Contents/Resources/app/napari/_lazy.py", line 48, in __getattr__
    submod = import_module(
  File "napari.app/Contents/Resources/Support/Python/Resources/lib/python3.9/importlib/__init__.py", line 127, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "napari.app/Contents/Resources/app/napari/viewer.py", line 8, in <module>
    from .components.viewer_model import ViewerModel
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app/napari/components/__init__.py", line 19, in <module>
    from .layerlist import LayerList
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app/napari/components/layerlist.py", line 9, in <module>
    from ..layers import Layer
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app/napari/layers/__init__.py", line 11, in <module>
    from .base import Layer
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app/napari/layers/base/__init__.py", line 1, in <module>
    from .base import Layer, no_op
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app/napari/layers/base/base.py", line 26, in <module>
    from ...utils.key_bindings import KeymapProvider
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app/napari/utils/key_bindings.py", line 46, in <module>
    from ..settings import get_settings
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app/napari/settings/__init__.py", line 5, in <module>
    from ._base import _NOT_SET
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app/napari/settings/_base.py", line 18, in <module>
    from ._yaml import PydanticYamlMixin
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app/napari/settings/_yaml.py", line 9, in <module>
    from ._fields import Version
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app/napari/settings/_fields.py", line 6, in <module>
    from ..utils.theme import available_themes
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app/napari/utils/theme.py", line 335, in <module>
    _install_npe2_themes(_themes)
  File "napari.app/Contents/Resources/app/napari/utils/theme.py", line 325, in _install_npe2_themes
    import npe2
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app_packages/npe2/__init__.py", line 8, in <module>
    from ._dynamic_plugin import DynamicPlugin
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app_packages/npe2/_dynamic_plugin.py", line 19, in <module>
    from ._plugin_manager import PluginManager
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app_packages/npe2/_plugin_manager.py", line 30, in <module>
    from ._command_registry import CommandRegistry
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app_packages/npe2/_command_registry.py", line 10, in <module>
    from .manifest import utils
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app_packages/npe2/manifest/__init__.py", line 2, in <module>
    from .schema import ENTRY_POINT, PluginManifest
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app_packages/npe2/manifest/schema.py", line 19, in <module>
    from .contributions import ContributionPoints
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app_packages/npe2/manifest/contributions/__init__.py", line 2, in <module>
    from ._contributions import ContributionPoints
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app_packages/npe2/manifest/contributions/_contributions.py", line 11, in <module>
    from ._widgets import WidgetContribution
  File "napari.app/Contents/Resources/app_packages/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import
    return original_import(name, *args, **kwargs)
  File "napari.app/Contents/Resources/app_packages/npe2/manifest/contributions/_widgets.py", line 14, in <module>
    class WidgetContribution(BaseModel, Executable[Widget]):
  File "pydantic/main.py", line 188, in pydantic.main.ModelMetaclass.__new__
  File "pydantic/typing.py", line 413, in pydantic.typing.resolve_annotations
TypeError: __init__() got an unexpected keyword argument 'is_class'
```
</details>

- How does the package installer create the napari-\<ver\>.app bundle? Is it already in the pkg file or is it created locally? Where is the code for this (I don't see it in `conda_bundle.py`)?
> Can you explain why there are two different application types?

`napari` is transitioning from Briefcase-based bundles (.zip containing a DMG) to constructor-based ones (.pkg). In the transition phase, both are available. The ones I am focused on are the PKGs; the DMGs will be eventually removed.

> How does the package installer create the napari-<ver>.app bundle?

This serves the role of a shortcut, so the main installation is somewhat hidden but the user can easily find it via the .app icon. It's created dynamically at install time:

- `constructor` will bundle a `conda-standalone` executable which will, in turn, contain a library called `menuinst`. 
- `menuinst` is the library used by Anaconda to create the shortcuts on Windows, but I rewrote it to support Linux and MacOS. 
- If a package distributes a JSON file under `$PREFIX/Menu`, then a shortcut will be created using those instructions. 
- None of this is upstreamed/released yet, so we build off a fork (uploaded to the `napari/label/bundle_tools` channel).


> `napari` is transitioning from Briefcase-based bundles (.zip containing a DMG) to constructor-based ones (.pkg). In the transition phase, both are available. The ones I am focused on are the PKGs; the DMGs will be eventually removed.

Aahh, that make sense.

> This serves the role of a shortcut, so the main installation is somewhat hidden but the user can easily find it via the .app icon. It's created dynamically at install time:
> 
> * `constructor` will bundle a `conda-standalone` executable which will, in turn, contain a library called `menuinst`.
> * `menuinst` is the library used by Anaconda to create the shortcuts on Windows, but I rewrote it to support Linux and MacOS.
> * If a package distributes a JSON file under `$PREFIX/Menu`, then a shortcut will be created using those instructions.
> * None of this is upstreamed/released yet, so we build off a fork (uploaded to the `napari/label/bundle_tools` channel).

- So, the `_conda.exe` executable file contains `menuist`?
- Is there a reason why you need `menuist` for macOS and Linux? These should just need to create "folders" with the appropriate structure and files. Can't this be accomplished with a script run by the pkg installer?
- Couldn't `micromamba` accomplish the same thing as `_conda.exe`?

> So, the _conda.exe executable file contains menuist?

Yes. And some patches not present in the regular conda. See [here](https://github.com/conda-forge/conda-standalone-feedstock).

> Is there a reason why you need menuist for macOS and Linux? These should just need to create "folders" with the appropriate structure and files. Can't this be accomplished with a script run by the pkg installer?

Well, sure, every operation in the installer can be done with a script after all. But since it's the same operation for _every_ installer, I added those features to `menuinst`. That way you don't have to figure out all the details about environment activation across OSs, icons, etc.

> Couldn't micromamba accomplish the same thing as _conda.exe?

Yes, as long as it reimplements the logic for Linux/OSX. Right now it only support the old-style, Windows-only menu config files.

@jaimergp, Thanks for your clarifications! It looks like the easiest way for me to proceed with Spyder is to leverage your work by using `constructor` from the napari conda-forge channel. I'll take a stab at it, but I'm sure I'll have more questions in the future.
@jaimergp, I cannot seem to find the source code for `napari-menu`; can you provide a link?
It just contains this file
https://github.com/napari/napari/blob/main/resources/conda_menu_config.json

It needs to be placer under $PREFIX/Menu, but you need the new menuinst, constructor fork, patched conda-standalone, and so on. 
@jaimergp, it appears that constructor from napari/label/bundle_tools does not support specifying an existing environment for the `extra_env`. I see you're actively working in fcp.py, so is this departure from standard constructor behavior intentional or is this transitory?
It should :/ It's what we are using for our CI, currently using extra_envs. See [here](https://github.com/napari/packaging/blob/7ac4206ce7664064c8efc41a2e0873bf091028a2/build_installers.py#L237-L262).
@jaimergp, I'm sorry, perhaps I wasn't clear. I meant the `environment`, as opposed to the `specs`, keyword to the `extra_env` item.

The offending lines of code are `fcp._main:381-392`, in the `constructor` package, retrieved from napari/label/bundle_tools conda channel. It does not match the code in the master branch of [jaimergp/constructor](https://github.com/jaimergp/constructor), so I don't know which branch it was built from.
```python
    for env_name, env_config in extra_envs.items():
        if verbose:
            print("Solving extra environment:", env_name)
        extra_envs_precs[env_name] = _solve_precs(
            f"{name}+{env_name}", version, download_dir, platform,
            channel_urls=env_config.get("channels") or channel_urls,
            channels_remap=env_config.get("channels_remap", channels_remap),
            specs=env_config["specs"],
            menu_packages=env_config.get("menu_packages", menu_packages),
            environment=None, environment_file=None, verbose=verbose,
            conda_exe=conda_exe
        )
```
in `_main`, `_solve_precs` is called, passing `env_config["specs"]` (`specs` may not exists) and _not_ passing `environment` or `environment_file`. Indeed, attempting to pass an environment name rather than specifications for an extra environment results in `KeyError: 'specs'`.
Ah, yes, sorry, now I see what you mean. The implementation found in the `bundle_tools` variants were the minimum ones needed for `napari`. Most of it has made it to `conda/constructor` upstream already, but there's no package in anaconda.org.

You could just pip install it from the github tarball though, and cross your fingers :D 

The reason I am not using `main` yet is because it lacks the menuinst shortcuts supports, and that will take longer to land...
> Ah, yes, sorry, now I see what you mean. The implementation found in the `bundle_tools` variants were the minimum ones needed for `napari`. Most of it has made it to `conda/constructor` upstream already, but there's no package in anaconda.org.
> 
> You could just pip install it from the github tarball though, and cross your fingers :D
> 
> The reason I am not using `main` yet is because it lacks the menuinst shortcuts supports, and that will take longer to land...

I see. So if `conda/constructor`'s main branch has most of the changes already, I can just work from that. But what do you recommend for `menuinst`? Is `napari/label/bundle_tools` okay for that still, or should I work from work from your fork's master branch? I assume that `napari/label/bundle_tools` is still okay for `conda-standalone`.
The problem with the menuinst-related features is that it requires code on `constructor` too 😬  So right now you have to choose between using `environment` in your `extra_envs` or fancy multi-platform shortcuts :D 
I can do without `environment` in `extra_envs`. I'm just trying to see what may or may not work for Spyder. If `bundle_tools` only has the minimum variants required for `napari`, then I may want to avoid that channel. In which case, I can take `constructor` from its main branch (or some branch from your fork?), `menuinst` from ... (which branch do you recommend?), and `conda-standalone` from ... (maybe `bundle_tools` is okay for this one?)
I'd say stick to the `bundle_tools` family for the first prototype. By the time you have something working I might need to move `napari` to upstream (sometime in September), at least some parts, and you'll get the benefits for free :) 
@jaimergp, I'm still confused about how `napari-menu` works. I've included files in the Menu directory as extra files in the package installer and they get installed in the appropriate Menu directory in the installed environment when the package installer is run, but `menuinst` still doesn't create the application. So, I am assuming that the Menu directory in the installed environment prefix must be placed there by a conda package. I'm still not clear on how this is done for napari. I don't see where you build the `napari-menu` package or its feedstock. Please advise.
Okay, so I figured out the menu packaging issue.
@jaimergp, is there a way to further customize the bundle launch script (`.../Contents/MacOS/App`)?
As far as I can see, `menuinst` configuration is limited to `activate` and `command` directives.
What kind of customization do you need? I'd expect that any custom code can be provided as a separate script anyway. Maybe the menu package can also ship its own little shell script that gets installed to `$CONDA_PREFIX/bin` and then you link the menu item to that script? Would that work?
> What kind of customization do you need? I'd expect that any custom code can be provided as a separate script anyway. Maybe the menu package can also ship its own little shell script that gets installed to `$CONDA_PREFIX/bin` and then you link the menu item to that script? Would that work?

All I need is to add a single line command _before_ environment activation.

```shell
#!/bin/bash

eval "$(/bin/bash -l -c "declare -x")"  # this must be before environment activation

eval "$("{conda_exe}" {activate} "{self.menu.prefix}")"

{command}
```

I don't know if there are any issues with using a symlink for the launch script in the application bundle. However, the launch script may depend on install-time environment variables, such as the root conda prefix and conda environment prefix.

It seems to me that building the script with `MacOSMenuItem._command` is the best thing to do, but just allow further control over its contents. Perhaps a new item, `script`, for the configuration json in which the developer can provide a path to a template file or multiline string directly. This template or multiline string could reference certain variables that `menuinst` recognizes, e.g. `{{ ROOT_PREFIX }}` or `{{ PREFIX }}` (or whatever, similar to `{{ MENU_DIR }}`).

I suppose this would preclude using the same script for all platforms, so maybe the `script` item is only valid under the `platform` items? Maybe taking precedence over an existing `command` item at the top level? Or maybe the developer just has to use separate configuration jsons for each platform if this level of customization is desired.

I see! Some kind of preactivation logic would make sense.

Before we talk about extend the [menuinst schema](https://github.com/conda-incubator/ceps/pull/8/files) though, let's see if we can make it work via other means. Two options to consider:

* The `activate` option is available there for your convenience, but I guess one can always switch it off and then build the whole script logic from scratch. Do we have enough info at runtime to recreate the activation command? I think we do.
* Another option would be to use conda activation scripts (those that go under `activate.d`), but maybe by then it's too late? 

Let me know what you think!
> I see! Some kind of preactivation logic would make sense.
> 
> Before we talk about extend the [menuinst schema](https://github.com/conda-incubator/ceps/pull/8/files) though, let's see if we can make it work via other means.

This seems prudent.

> * The `activate` option is available there for your convenience, but I guess one can always switch it off and then build the whole script logic from scratch. Do we have enough info at runtime to recreate the activation command? I think we do.

This would be fine for me. I suppose we would have to make a few changes to `MacOSMenuItem._command` and we could not necessarily use the same configuration json for all platforms?

Yes, you are not forced to use a multiplatform JSON if you don't want. It's offered as a possibility so having menu items does not force packages to become non-noarch builds.
@jaimergp, I have discovered another issue with respect to macOS integration. Keyboard shortcuts and function key assignment (for touchbars) established in macOS preferences are not respected. This is resolved by creating a symbolic link `Contents/MacOS/python` that points to `$PREFIX/bin/python`, and using _this_ python to start the application.

For example:
```
/Users/rclary/Applications/Spyder.app
└── Contents
    ├── Info.plist
    ├── MacOS
    │   ├── Spyder
    │   └── python -> /Users/rclary/Library/spyder-5.4.0.dev0/envs/spyder-5.4.0.dev0/bin/python
    ├── PkgInfo
    └── Resources
        └── spyder.icns
```
Where the application executable, `Spyder`, is:
```bash
#!/bin/bash
eval "$("/Users/rclary/Library/spyder-5.4.0.dev0/_conda.exe" shell.bash activate "/Users/rclary/Library/spyder-5.4.0.dev0/envs/spyder-5.4.0.dev0")"
$(dirname $BASH_SOURCE)/python /Users/rclary/Library/spyder-5.4.0.dev0/envs/spyder-5.4.0.dev0/bin/spyder "$@"
```

It seems to me that this symbolic link should be created by `menuinst` as standard behavior (or at least an option). This is how `python.app` (via `pythonw`) works. Can this be included in your merge requests to `menuinst`?

Hm, I wonder how this hasn't showed up in `napari`. I think they have a workaround to restart the process with pythonw if available, so maybe that's why.

I do recall seeing mentions of `pythonw` and `python.app` while doing my research for the rewrite, but since it apparently worked just fine, I didn't consider them. Turns out the issue is more subtle than that. I'll take a look and see if we can fix it in a more general way (e.g. what happens if the shortcut does not point to a Python script or entry point?).
What if you add `python.app` as a dependency for your the package shipping your menuinst metadata, and then have the shortcut use `$PREFIX/pythonw` to launch the entry point? Does that fix it? I think this would be equivalent to what `napari`'s workaround is using.
The issue does show up in napari. One could use `pythonw` (which just links to `python.app/Contents/MacOS/python`, which just links to `/bin/python`), but then `python.app` gets integrated to the macOS, not the user application. Application shortcuts in macOS preferences would have to use python.app rather than the user's application and "python" will show up in the menubar instead of the user's app name.

The reason is that the _final_ application bundle executable is inside `python.app`. This is fine for most commandline python applications because you _want_ "python" to integrate to macOS. For the user's application, we just need to apply the same _technique_, i.e. have python executable "appear" to originate from inside the user's application bundle.

The two screencasts illustrate.

Unadulterated napari does not integrate shortcuts in macOS preferences...
![integration](https://user-images.githubusercontent.com/9618975/190185946-712b721e-14e9-4e5c-ba01-fc3ebc27a12a.gif)

Modified napari has improved integration with macOS...
![integration-2](https://user-images.githubusercontent.com/9618975/190186313-b7bc1a53-e12b-4477-b26d-599a96271c57.gif)


This is super informative @mrclary, I'll look into it this week!
@jaimergp, I notice that your fork of `constructor` claims no support for `post_install` or `pre_install` (https://github.com/jaimergp/constructor/blob/master/CONSTRUCT.md) but nevertheless I can provide a post install script that successfully runs. However, there appears to be limited variable substitution. The only ones available for the post install script that I can find are:
- `__PKG_NAME_LOWER__`
- `__NAME__`
- `__VERSION__`
- `__CHANNELS__`
- `__WRITE_CONDARC__`
- `__SHORTCUTS__`

Unfortunately, the most important information the package installer should provide to a post-install script is the locations of the environments produced (`__NAME_LOWER__` I think?). For my application, I can reconstruct it for narrow cases, but of course this won't work in more general cases.
I think in the fork you still have `$PREFIX`, `%PREFIX%` on Linux / Windows and `$2/__NAME_LOWER__` on MacOS. This has been improved significantly in some of the recent PRs, too. In `main`, you won't need to infer PREFIX on MacOS. 
> I think in the fork you still have `$PREFIX`, `%PREFIX%` on Linux / Windows and `$2/__NAME_LOWER__` on MacOS. This has been improved significantly in some of the recent PRs, too. In `main`, you won't need to infer PREFIX on MacOS.

I don't know about the fork, I've only been using what is on napari/label/bundle_tools channel (ver 4.0b2); `__NAME_LOWER__` does not appear to work. However, if the main branch has `PREFIX` provided for macOS, then that should work. When will that propagate to napari/label/bundle_tools? Is that a nightly?
@jaimergp, BTW, thank you so much for your help on this. You've done a lot of work getting this working for napari and the Spyder team is benefitting from that.

Resolving patch:
diff --git a/installers-conda/build_conda_pkgs.py b/installers-conda/build_conda_pkgs.py
new file mode 100644
--- /dev/null
+++ b/installers-conda/build_conda_pkgs.py
@@ -0,0 +1,333 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""
+Build conda packages to local channel.
+
+This module builds conda packages for Spyder and external-deps for
+inclusion in the conda-based installer. The Following classes are
+provided for each package:
+    SpyderCondaPkg
+    PylspCondaPkg
+    QdarkstyleCondaPkg
+    QtconsoleCondaPkg
+    SpyderKernelsCondaPkg
+
+Spyder will be packaged from this repository (in its checked-out state).
+qdarkstyle, qtconsole, and spyder-kernels will be packaged from the
+external-deps directory of this repository (in its checked-out state).
+Python-lsp-server, however, will be packaged from the upstream remote
+at the same commit as the external-deps state.
+
+Alternatively, any external-deps may be packaged from a local git repository
+(in its checked out state) by setting the appropriate environment variable
+from the following:
+    PYTHON_LSP_SERVER_SOURCE
+    QDARKSTYLE_SOURCE
+    QTCONSOLE_SOURCE
+    SPYDER_KERNELS_SOURCE
+"""
+
+# Standard library imports
+import os
+import re
+from argparse import ArgumentParser
+from configparser import ConfigParser
+from datetime import timedelta
+from logging import Formatter, StreamHandler, getLogger
+from pathlib import Path
+from shutil import rmtree
+from subprocess import check_call
+from textwrap import dedent
+from time import time
+
+# Third-party imports
+from git import Repo
+from ruamel.yaml import YAML
+from setuptools_scm import get_version
+
+fmt = Formatter('%(asctime)s [%(levelname)s] [%(name)s] -> %(message)s')
+h = StreamHandler()
+h.setFormatter(fmt)
+logger = getLogger('BuildCondaPkgs')
+logger.addHandler(h)
+logger.setLevel('INFO')
+
+HERE = Path(__file__).parent
+DIST = HERE / "dist"
+RESOURCES = HERE / "resources"
+EXTDEPS = HERE.parent / "external-deps"
+SPECS = DIST / "specs.yaml"
+
+DIST.mkdir(exist_ok=True)
+
+
+def remove_readonly(func, path, exc):
+    """
+    Change readonly status of file.
+    Windows file systems may require this if rmdir fails
+    """
+    import errno, stat
+    excvalue = exc[1]
+    if func in (os.rmdir, os.remove, os.unlink) and excvalue.errno == errno.EACCES:
+        os.chmod(path, stat.S_IRWXU| stat.S_IRWXG| stat.S_IRWXO) # 0777
+        func(path)
+    else:
+        raise
+
+
+class BuildCondaPkg:
+    name = None
+    src_path = None
+    feedstock = None
+    shallow_ver = None
+
+    _yaml = YAML(typ='jinja2')
+    _yaml.indent(mapping=2, sequence=4, offset=2)
+
+    def __init__(self, data={}, debug=False):
+        # ---- Setup logger
+        self.logger = getLogger(self.__class__.__name__)
+        if not self.logger.handlers:
+            self.logger.addHandler(h)
+        self.logger.setLevel('INFO')
+
+        self.debug = debug
+
+        self._get_source()
+        self._get_version()
+
+        self.data = {'version': self.version}
+        self.data.update(data)
+
+        self.fdstk_path = HERE / self.feedstock.split("/")[-1]
+
+        self.yaml = None
+
+        self._patched_meta = False
+        self._patched_build = False
+
+    def _get_source(self):
+        self._build_cleanup()
+
+        if not self.src_path.exists():
+            cfg = ConfigParser()
+            cfg.read(EXTDEPS / self.name / '.gitrepo')
+            # Clone from remote
+            repo = Repo.clone_from(
+                cfg['subrepo']['remote'],
+                to_path=self.src_path, shallow_exclude=self.shallow_ver
+            )
+            repo.git.checkout(cfg['subrepo']['commit'])
+
+    def _build_cleanup(self):
+        if self.src_path.exists() and self.src_path == HERE / self.name:
+            logger.info(f"Removing {self.src_path}...")
+            rmtree(self.src_path, onerror=remove_readonly)
+
+    def _get_version(self):
+        self.version = get_version(self.src_path).split('+')[0]
+
+    def _clone_feedstock(self):
+        if self.fdstk_path.exists():
+            self.logger.info(f"Removing existing {self.fdstk_path}...")
+            rmtree(self.fdstk_path, onerror=remove_readonly)
+
+        self.logger.info(f"Cloning feedstock to {self.fdstk_path}...")
+        check_call(["git", "clone", str(self.feedstock), str(self.fdstk_path)])
+
+    def _patch_meta(self):
+        pass
+
+    def patch_meta(self):
+        if self._patched_meta:
+            return
+
+        self.logger.info("Patching 'meta.yaml'...")
+
+        file = self.fdstk_path / "recipe" / "meta.yaml"
+        text = file.read_text()
+        for k, v in self.data.items():
+            text = re.sub(f".*set {k} =.*", f'{{% set {k} = "{v}" %}}', text)
+
+        self.yaml = self._yaml.load(text)
+
+        self.yaml['source'] = {'path': str(self.src_path)}
+
+        self.yaml.pop('test', None)
+        if 'outputs' in self.yaml:
+            for out in self.yaml['outputs']:
+                out.pop('test', None)
+
+        self._patch_meta()
+
+        self._yaml.dump_all([self.yaml], file)
+
+        self._patched_meta = True
+
+    def _patch_build(self):
+        pass
+
+    def patch_build(self):
+        if self._patched_build:
+            return
+
+        self.logger.info("Patching build script...")
+        self._patch_build()
+        self._patched_build = True
+
+    def build(self):
+        t0 = time()
+        try:
+            # self._git_init_src_path()
+            self._clone_feedstock()
+            self.patch_meta()
+            self.patch_build()
+
+            self.logger.info("Building conda package "
+                             f"{self.name}={self.version}...")
+            check_call(
+                ["mamba", "mambabuild", str(self.fdstk_path / "recipe")]
+            )
+        finally:
+            self._patched_meta = False
+            self._patched_build = False
+            if not self.debug:
+                self.logger.info(f"Removing {self.fdstk_path}...")
+                rmtree(self.fdstk_path, onerror=remove_readonly)
+
+            self._build_cleanup()
+
+            elapse = timedelta(seconds=int(time() - t0))
+            self.logger.info(f"Build time = {elapse}")
+
+
+class SpyderCondaPkg(BuildCondaPkg):
+    name = "spyder"
+    src_path = HERE.parent
+    feedstock = "https://github.com/conda-forge/spyder-feedstock"
+    shallow_ver = "v5.3.2"
+
+    def _patch_meta(self):
+        self.yaml['build'].pop('osx_is_app', None)
+        self.yaml.pop('app', None)
+
+        patches = self.yaml['source'].get('patches', [])
+        patches.append(str(RESOURCES / "installers-conda.patch"))
+        self.yaml['source']['patches'] = patches
+
+    def _patch_build(self):
+        if os.name == 'posix':
+            file = self.fdstk_path / "recipe" / "build.sh"
+            build_patch = RESOURCES / "build-patch.sh"
+            text = file.read_text()
+            text += build_patch.read_text()
+            file.write_text(text)
+        if os.name == 'nt':
+            file = self.fdstk_path / "recipe" / "bld.bat"
+            text = file.read_text()
+            text = text.replace(
+                r"copy %RECIPE_DIR%\menu-windows.json %MENU_DIR%\spyder_shortcut.json",
+                """powershell -Command"""
+                r""" "(gc %SRC_DIR%\installers-conda\resources\spyder-menu.json)"""
+                r""" -replace '__PKG_VERSION__', '%PKG_VERSION%' | """
+                r"""Out-File -encoding ASCII %MENU_DIR%\spyder-menu.json" """
+            )
+            file.write_text(text)
+
+class PylspCondaPkg(BuildCondaPkg):
+    name = "python-lsp-server"
+    src_path = Path(
+        os.environ.get('PYTHON_LSP_SERVER_SOURCE', HERE / name)
+    )
+    feedstock = "https://github.com/conda-forge/python-lsp-server-feedstock"
+    shallow_ver = "v1.4.1"
+
+
+class QdarkstyleCondaPkg(BuildCondaPkg):
+    name = "qdarkstyle"
+    src_path = Path(
+        os.environ.get('QDARKSTYLE_SOURCE', HERE / name)
+    )
+    feedstock = "https://github.com/conda-forge/qdarkstyle-feedstock"
+    shallow_ver = "v3.0.2"
+
+
+class QtconsoleCondaPkg(BuildCondaPkg):
+    name = "qtconsole"
+    src_path = Path(
+        os.environ.get('QTCONSOLE_SOURCE', HERE / name)
+    )
+    feedstock = "https://github.com/conda-forge/qtconsole-feedstock"
+    shallow_ver = "5.3.1"
+
+    def _patch_meta(self):
+        for out in self.yaml['outputs']:
+            out.pop("test", None)
+
+
+class SpyderKernelsCondaPkg(BuildCondaPkg):
+    name = "spyder-kernels"
+    src_path = Path(
+        os.environ.get('SPYDER_KERNELS_SOURCE', HERE / name)
+    )
+    feedstock = "https://github.com/conda-forge/spyder-kernels-feedstock"
+    shallow_ver = "v2.3.1"
+
+
+PKGS = {
+    SpyderCondaPkg.name: SpyderCondaPkg,
+    PylspCondaPkg.name: PylspCondaPkg,
+    QdarkstyleCondaPkg.name: QdarkstyleCondaPkg,
+    QtconsoleCondaPkg.name: QtconsoleCondaPkg,
+    SpyderKernelsCondaPkg.name: SpyderKernelsCondaPkg
+}
+
+if __name__ == "__main__":
+    p = ArgumentParser(
+        description=dedent(
+            """
+            Build conda packages from local Spyder and external-deps sources.
+            Alternative git repo for python-lsp-server may be provided by
+            setting the environment variable PYTHON_LSP_SERVER_SOURCE,
+            otherwise the upstream remote will be used. All other external-deps
+            use the subrepo source within the Spyder repo.
+            """
+        ),
+        usage="python build_conda_pkgs.py "
+              "[--build BUILD [BUILD] ...] [--debug]",
+    )
+    p.add_argument(
+        '--debug', action='store_true', default=False,
+        help="Do not remove cloned feedstocks"
+    )
+    p.add_argument(
+        '--build', nargs="+", default=PKGS.keys(),
+        help=("Space-separated list of packages to build. "
+              f"Default is {list(PKGS.keys())}")
+    )
+    args = p.parse_args()
+
+    logger.info(f"Building local conda packages {list(args.build)}...")
+    t0 = time()
+
+    yaml = YAML()
+    yaml.indent(mapping=2, sequence=4, offset=2)
+
+    for k in args.build:
+        if SPECS.exists():
+            specs = yaml.load(SPECS.read_text())
+        else:
+            specs = {k: "" for k in PKGS}
+
+        pkg = PKGS[k](debug=args.debug)
+        pkg.build()
+        specs[k] = "=" + pkg.version
+
+        yaml.dump(specs, SPECS)
+
+    elapse = timedelta(seconds=int(time() - t0))
+    logger.info(f"Total build time = {elapse}")
diff --git a/installers-conda/build_installers.py b/installers-conda/build_installers.py
new file mode 100644
--- /dev/null
+++ b/installers-conda/build_installers.py
@@ -0,0 +1,405 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+
+"""
+Create Spyder installers using `constructor`.
+
+It creates a `construct.yaml` file with the needed settings
+and then runs `constructor`.
+
+Some environment variables we use:
+
+CONSTRUCTOR_TARGET_PLATFORM:
+    conda-style platform (as in `platform` in `conda info -a` output)
+CONSTRUCTOR_CONDA_EXE:
+    when the target platform is not the same as the host, constructor
+    needs a path to a conda-standalone (or micromamba) executable for
+    that platform. needs to be provided in this env var in that case!
+CONSTRUCTOR_SIGNING_CERTIFICATE:
+    Path to PFX certificate to sign the EXE installer on Windows
+"""
+
+# Standard library imports
+from argparse import ArgumentParser
+from datetime import timedelta
+from distutils.spawn import find_executable
+from functools import partial
+import json
+from logging import getLogger
+import os
+from pathlib import Path
+import platform
+import re
+from subprocess import check_call
+import sys
+from textwrap import dedent, indent
+from time import time
+import zipfile
+
+# Third-party imports
+from ruamel.yaml import YAML
+
+# Local imports
+from build_conda_pkgs import HERE, DIST, RESOURCES, SPECS, h, get_version
+
+logger = getLogger('BuildInstallers')
+logger.addHandler(h)
+logger.setLevel('INFO')
+
+APP = "Spyder"
+SPYREPO = HERE.parent
+WINDOWS = os.name == "nt"
+MACOS = sys.platform == "darwin"
+LINUX = sys.platform.startswith("linux")
+TARGET_PLATFORM = os.environ.get("CONSTRUCTOR_TARGET_PLATFORM")
+PY_VER = f"{sys.version_info.major}.{sys.version_info.minor}"
+
+if TARGET_PLATFORM == "osx-arm64":
+    ARCH = "arm64"
+else:
+    ARCH = (platform.machine() or "generic").lower().replace("amd64", "x86_64")
+if WINDOWS:
+    EXT, OS = "exe", "Windows"
+elif LINUX:
+    EXT, OS = "sh", "Linux"
+elif MACOS:
+    EXT, OS = "pkg", "macOS"
+else:
+    raise RuntimeError(f"Unrecognized OS: {sys.platform}")
+
+scientific_packages = {
+    "cython": "",
+    "matplotlib": "",
+    "numpy": "",
+    "openpyxl": "",
+    "pandas": "",
+    "scipy": "",
+    "sympy": "",
+}
+
+# ---- Parse arguments
+p = ArgumentParser()
+p.add_argument(
+    "--no-local", action="store_true",
+    help="Do not use local conda packages"
+)
+p.add_argument(
+    "--debug", action="store_true",
+    help="Do not delete build files"
+)
+p.add_argument(
+    "--arch", action="store_true",
+    help="Print machine architecture tag and exit.",
+)
+p.add_argument(
+    "--ext", action="store_true",
+    help="Print installer extension for this platform and exit.",
+)
+p.add_argument(
+    "--artifact-name", action="store_true",
+    help="Print computed artifact name and exit.",
+)
+p.add_argument(
+    "--extra-specs", nargs="+", default=[],
+    help="One or more extra conda specs to add to the installer",
+)
+p.add_argument(
+    "--licenses", action="store_true",
+    help="Post-process licenses AFTER having built the installer. "
+    "This must be run as a separate step.",
+)
+p.add_argument(
+    "--images", action="store_true",
+    help="Generate background images from the logo (test only)",
+)
+p.add_argument(
+    "--cert-id", default=None,
+    help="Apple Developer ID Application certificate common name."
+)
+p.add_argument(
+    "--lite", action="store_true",
+    help=f"Do not include packages {scientific_packages.keys()}"
+)
+args = p.parse_args()
+
+yaml = YAML()
+yaml.indent(mapping=2, sequence=4, offset=2)
+indent4 = partial(indent, prefix="    ")
+
+SPYVER = get_version(SPYREPO).strip().split("+")[0]
+
+specs = {
+    "spyder": "=" + SPYVER,
+    "paramiko": "",
+    "pyxdg": "",
+}
+
+if SPECS.exists():
+    logger.info(f"Reading specs from {SPECS}...")
+    _specs = yaml.load(SPECS.read_text())
+    specs.update(_specs)
+else:
+    logger.info(f"Did not read specs from {SPECS}")
+
+if not args.lite:
+    specs.update(scientific_packages)
+
+for spec in args.extra_specs:
+    k, *v = re.split('([<>= ]+)', spec)
+    specs[k] = "".join(v).strip()
+    if k == "spyder":
+        SPYVER = v[-1]
+
+OUTPUT_FILE = DIST / f"EXPERIMENTAL-{APP}-{SPYVER}-{OS}-{ARCH}.{EXT}"
+INSTALLER_DEFAULT_PATH_STEM = f"{APP}-{SPYVER}"
+
+
+def _generate_background_images(installer_type):
+    """This requires Pillow."""
+    if installer_type == "sh":
+        # shell installers are text-based, no graphics
+        return
+
+    from PIL import Image
+
+    logo_path = SPYREPO / "img_src" / "spyder.png"
+    logo = Image.open(logo_path, "r")
+
+    if installer_type in ("exe", "all"):
+        sidebar = Image.new("RGBA", (164, 314), (0, 0, 0, 0))
+        sidebar.paste(logo.resize((101, 101)), (32, 180))
+        output = DIST / "spyder_164x314.png"
+        sidebar.save(output, format="png")
+
+        banner = Image.new("RGBA", (150, 57), (0, 0, 0, 0))
+        banner.paste(logo.resize((44, 44)), (8, 6))
+        output = DIST / "spyder_150x57.png"
+        banner.save(output, format="png")
+
+    if installer_type in ("pkg", "all"):
+        _logo = Image.new("RGBA", logo.size, "WHITE")
+        _logo.paste(logo, mask=logo)
+        background = Image.new("RGBA", (1227, 600), (0, 0, 0, 0))
+        background.paste(_logo.resize((148, 148)), (95, 418))
+        output = DIST / "spyder_1227x600.png"
+        background.save(output, format="png")
+
+
+def _get_condarc():
+    # we need defaults for tensorflow and others on windows only
+    defaults = "- defaults" if WINDOWS else ""
+    prompt = "[spyder]({default_env}) "
+    contents = dedent(
+        f"""
+        channels:  #!final
+          - conda-forge
+          {defaults}
+        repodata_fns:  #!final
+          - repodata.json
+        auto_update_conda: false  #!final
+        notify_outdated_conda: false  #!final
+        channel_priority: strict  #!final
+        env_prompt: '{prompt}'  #! final
+        """
+    )
+    # the undocumented #!final comment is explained here
+    # https://www.anaconda.com/blog/conda-configuration-engine-power-users
+    file = DIST / "condarc"
+    file.write_text(contents)
+
+    return str(file)
+
+
+def _definitions():
+    condarc = _get_condarc()
+    definitions = {
+        "name": APP,
+        "company": "Spyder-IDE",
+        "reverse_domain_identifier": "org.spyder-ide.Spyder",
+        "version": SPYVER,
+        "channels": [
+            "napari/label/bundle_tools",
+            "conda-forge",
+        ],
+        "conda_default_channels": ["conda-forge"],
+        "specs": [
+            "python",
+            "conda",
+            "mamba",
+            "pip",
+        ],
+        "installer_filename": OUTPUT_FILE.name,
+        "initialize_by_default": False,
+        "license_file": str(RESOURCES / "bundle_license.rtf"),
+        "extra_envs": {
+            f"spyder-{SPYVER}": {
+                "specs": [k + v for k, v in specs.items()],
+            },
+        },
+        "menu_packages": [
+            "spyder",
+        ],
+        "extra_files": {
+            str(RESOURCES / "bundle_readme.md"): "README.txt",
+            condarc: ".condarc",
+        },
+    }
+
+    if not args.no_local:
+        definitions["channels"].insert(0, "local")
+
+    if LINUX:
+        definitions["default_prefix"] = os.path.join(
+            "$HOME", ".local", INSTALLER_DEFAULT_PATH_STEM
+        )
+        definitions["license_file"] = str(SPYREPO / "LICENSE.txt")
+        definitions["installer_type"] = "sh"
+
+    if MACOS:
+        welcome_text_tmpl = \
+            (RESOURCES / "osx_pkg_welcome.rtf.tmpl").read_text()
+        welcome_file = DIST / "osx_pkg_welcome.rtf"
+        welcome_file.write_text(
+            welcome_text_tmpl.replace("__VERSION__", SPYVER))
+
+        # These two options control the default install location:
+        # ~/<default_location_pkg>/<pkg_name>
+        definitions.update(
+            {
+                "pkg_name": INSTALLER_DEFAULT_PATH_STEM,
+                "default_location_pkg": "Library",
+                "installer_type": "pkg",
+                "welcome_image": str(DIST / "spyder_1227x600.png"),
+                "welcome_file": str(welcome_file),
+                "conclusion_text": "",
+                "readme_text": "",
+                "post_install": str(RESOURCES / "post-install.sh"),
+            }
+        )
+
+        if args.cert_id:
+            definitions["signing_identity_name"] = args.cert_id
+            definitions["notarization_identity_name"] = args.cert_id
+
+    if WINDOWS:
+        definitions["conda_default_channels"].append("defaults")
+        definitions.update(
+            {
+                "welcome_image": str(DIST / "spyder_164x314.png"),
+                "header_image": str(DIST / "spyder_150x57.png"),
+                "icon_image": str(SPYREPO / "img_src" / "spyder.ico"),
+                "register_python_default": False,
+                "default_prefix": os.path.join(
+                    "%LOCALAPPDATA%", INSTALLER_DEFAULT_PATH_STEM
+                ),
+                "default_prefix_domain_user": os.path.join(
+                    "%LOCALAPPDATA%", INSTALLER_DEFAULT_PATH_STEM
+                ),
+                "default_prefix_all_users": os.path.join(
+                    "%ALLUSERSPROFILE%", INSTALLER_DEFAULT_PATH_STEM
+                ),
+                "check_path_length": False,
+                "installer_type": "exe",
+            }
+        )
+
+        signing_certificate = os.environ.get("CONSTRUCTOR_SIGNING_CERTIFICATE")
+        if signing_certificate:
+            definitions["signing_certificate"] = signing_certificate
+
+    if definitions.get("welcome_image") or definitions.get("header_image"):
+        _generate_background_images(definitions.get("installer_type", "all"))
+
+    return definitions
+
+
+def _constructor():
+    """
+    Create a temporary `construct.yaml` input file and
+    run `constructor`.
+    """
+    constructor = find_executable("constructor")
+    if not constructor:
+        raise RuntimeError("Constructor must be installed and in PATH.")
+
+    definitions = _definitions()
+
+    cmd_args = [constructor, "-v", "--output-dir", str(DIST)]
+    if args.debug:
+        cmd_args.append("--debug")
+    conda_exe = os.environ.get("CONSTRUCTOR_CONDA_EXE")
+    if TARGET_PLATFORM and conda_exe:
+        cmd_args += ["--platform", TARGET_PLATFORM, "--conda-exe", conda_exe]
+    cmd_args.append(str(DIST))
+
+    env = os.environ.copy()
+    env["CONDA_CHANNEL_PRIORITY"] = "strict"
+
+    logger.info("Command: " + " ".join(cmd_args))
+    logger.info("Configuration:")
+    yaml.dump(definitions, sys.stdout)
+
+    yaml.dump(definitions, DIST / "construct.yaml")
+
+    check_call(cmd_args, env=env)
+
+
+def licenses():
+    info_path = DIST / "info.json"
+    try:
+        info = json.load(info_path)
+    except FileNotFoundError:
+        print(
+            "!! Use `constructor --debug` to write info.json and get licenses",
+            file=sys.stderr,
+        )
+        raise
+
+    zipname = DIST / f"licenses.{OS}-{ARCH}.zip"
+    output_zip = zipfile.ZipFile(zipname, mode="w",
+                                 compression=zipfile.ZIP_DEFLATED)
+    output_zip.write(info_path)
+    for package_id, license_info in info["_licenses"].items():
+        package_name = package_id.split("::", 1)[1]
+        for license_type, license_files in license_info.items():
+            for i, license_file in enumerate(license_files, 1):
+                arcname = (f"{package_name}.{license_type.replace(' ', '_')}"
+                           f".{i}.txt")
+                output_zip.write(license_file, arcname=arcname)
+    output_zip.close()
+    return zipname.resolve()
+
+
+def main():
+    t0 = time()
+    try:
+        DIST.mkdir(exist_ok=True)
+        _constructor()
+        assert Path(OUTPUT_FILE).exists()
+        logger.info(f"Created {OUTPUT_FILE}")
+    finally:
+        elapse = timedelta(seconds=int(time() - t0))
+        logger.info(f"Build time: {elapse}")
+
+
+if __name__ == "__main__":
+    if args.arch:
+        print(ARCH)
+        sys.exit()
+    if args.ext:
+        print(EXT)
+        sys.exit()
+    if args.artifact_name:
+        print(OUTPUT_FILE)
+        sys.exit()
+    if args.licenses:
+        print(licenses())
+        sys.exit()
+    if args.images:
+        _generate_background_images()
+        sys.exit()
+
+    main()


Test patch: