Repository: spyder-ide/spyder

Issue:
Build string in conda package url for Spyder in the conda-lock file release asset does not match conda-forge
The installer workflow generates a conda-lock file for updating installer-based Spyder versions. The explicit url specification in the file has the form:
```
https://conda.anaconda.org/conda-forge/<platform>/spyder-<version>-<build-string>.conda
```
There are two issues with the url in the conda-lock file.

1. For non-stable releases (e.g. `6.0.0b2`) `label/spyder_dev/` is missing from the url.
2. The `<build-string>` portion does not match that of the package on conda-forge. This is because the `<build-string>` contains a hash that is automatically generated by `conda-build` based on the contents of `meta.yaml` in the recipe, and this file will differ slightly between our CI and conda-forge.

@spyder-ide/core-developers, we should discuss possible solutions.


Discussion:

Resolving patch:
diff --git a/installers-conda/build_conda_pkgs.py b/installers-conda/build_conda_pkgs.py
--- a/installers-conda/build_conda_pkgs.py
+++ b/installers-conda/build_conda_pkgs.py
@@ -43,6 +43,9 @@
 BUILD.mkdir(exist_ok=True)
 SPYPATCHFILE = BUILD / "installers-conda.patch"
 
+yaml = YAML()
+yaml.indent(mapping=2, sequence=4, offset=2)
+
 
 class BuildCondaPkg:
     """Base class for building a conda package for conda-based installer"""
@@ -63,6 +66,7 @@ def __init__(self, data={}, debug=False, shallow=False):
 
         self._bld_src = BUILD / self.name
         self._fdstk_path = BUILD / self.feedstock.split("/")[-1]
+        self._patchfile = self._fdstk_path / "recipe" / "version.patch"
 
         self._get_source(shallow=shallow)
         self._get_version()
@@ -70,7 +74,8 @@ def __init__(self, data={}, debug=False, shallow=False):
         self.data = {'version': self.version}
         self.data.update(data)
 
-        self._patch_source()
+        self.recipe_append = {}
+        self.recipe_clobber = {}
 
         self._recipe_patched = False
 
@@ -125,21 +130,23 @@ def _get_version(self):
     def _patch_source(self):
         pass
 
-    def _patch_meta(self, meta):
-        return meta
-
-    def patch_recipe(self):
-        """
-        Patch conda build recipe
+    def _patch_conda_build_config(self):
+        file = self._fdstk_path / "recipe" / "conda_build_config.yaml"
+        if file.exists():
+            contents = yaml.load(file.read_text())
+            file.rename(file.parent / ("_" + file.name))  # copy of original
+        else:
+            contents = {}
 
-        1. Patch meta.yaml
-        2. Patch build script
-        """
-        if self._recipe_patched:
-            return
+        pyver = sys.version_info
+        contents['python'] = [f"{pyver.major}.{pyver.minor}.* *_cpython"]
+        yaml.dump(contents, file)
 
-        self.logger.info("Patching 'meta.yaml'...")
+        self.logger.info(
+            f"Patched 'conda_build_config.yaml' contents:\n{file.read_text()}"
+        )
 
+    def _patch_meta(self):
         file = self._fdstk_path / "recipe" / "meta.yaml"
         meta = file.read_text()
 
@@ -147,36 +154,80 @@ def patch_recipe(self):
         for k, v in self.data.items():
             meta = re.sub(f".*set {k} =.*", f'{{% set {k} = "{v}" %}}', meta)
 
-        # Replace source, but keep patches
-        meta = re.sub(r'^(source:\n)(  (url|sha256):.*\n)*',
-                      rf'\g<1>  path: {self._bld_src.as_posix()}\n',
-                      meta, flags=re.MULTILINE)
-
-        meta = self._patch_meta(meta)
-
         file.rename(file.parent / ("_" + file.name))  # keep copy of original
         file.write_text(meta)
 
         self.logger.info(f"Patched 'meta.yaml' contents:\n{file.read_text()}")
 
+    def _add_recipe_append(self):
+        if self._patchfile.exists():
+            self.recipe_append.update(
+                {"source": {"patches": [self._patchfile.name]}}
+            )
+
+        if self.recipe_append:
+            file = self._fdstk_path / "recipe" / "recipe_append.yaml"
+            yaml.dump(self.recipe_append, file)
+            self.logger.info(
+                f"'recipe_append.yaml' contents:\n{file.read_text()}"
+            )
+        else:
+            self.logger.info("Skipping 'recipe_append.yaml'.")
+
+    def _add_recipe_clobber(self):
+        self.recipe_clobber.update({
+            "source": {
+                "url": None,
+                "sha256": None,
+                "path": self._bld_src.as_posix()},
+        })
+
+        if self.recipe_clobber:
+            file = self._fdstk_path / "recipe" / "recipe_clobber.yaml"
+            yaml.dump(self.recipe_clobber, file)
+            self.logger.info(
+                f"'recipe_clobber.yaml' contents:\n{file.read_text()}"
+            )
+        else:
+            self.logger.info("Skipping 'recipe_clobber.yaml'.")
+
+    def patch_recipe(self):
+        """
+        Patch conda build recipe
+
+        1. Patch conda_build_config.yaml
+        2. Patch meta.yaml
+        3. Add recipe_append.yaml
+        4. Add recipe_clobber.yaml
+        """
+        if self._recipe_patched:
+            return
+
+        self._patch_conda_build_config()
+        self._patch_meta()
+        self._add_recipe_append()
+        self._add_recipe_clobber()
+
         self._recipe_patched = True
 
     def build(self):
         """
         Build the conda package.
 
-        1. Patch the recipe
-        2. Build the package
-        3. Remove cloned repositories
+        1. Patch source
+        2. Patch the recipe
+        3. Build the package
+        4. Remove cloned repositories
         """
         t0 = time()
         try:
+            self._patch_source()
             self.patch_recipe()
 
             self.logger.info("Building conda package "
                              f"{self.name}={self.version}...")
             check_call([
-                "conda", "mambabuild",
+                "conda", "build",
                 "--skip-existing", "--build-id-pat={n}",
                 "--no-test", "--no-anaconda-upload",
                 str(self._fdstk_path / "recipe")
@@ -202,6 +253,7 @@ class SpyderCondaPkg(BuildCondaPkg):
 
     def _patch_source(self):
         self.logger.info("Patching Spyder source...")
+
         file = self._bld_src / "spyder/__init__.py"
         file_text = file.read_text()
         ver_str = tuple(self.version.split('.'))
@@ -213,14 +265,14 @@ def _patch_source(self):
         )
         file.write_text(file_text)
 
-        self.repo.git.diff(
-            output=(self._fdstk_path / "recipe" / "version.patch").as_posix()
-        )
-        self.repo.git.stash()
+        # Only write patch if necessary
+        if self.repo.git.diff():
+            self.logger.info(f"Creating {self._patchfile.name}...")
+            self.repo.git.diff(output=self._patchfile.as_posix())
+            self.repo.git.stash()
 
-    def _patch_meta(self, meta):
+    def patch_recipe(self):
         # Get current Spyder requirements
-        yaml = YAML()
         current_requirements = ['python']
         current_requirements += yaml.load(
             REQ_MAIN.read_text())['dependencies']
@@ -240,16 +292,11 @@ def _patch_meta(self, meta):
                 REQ_LINUX.read_text())['dependencies']
             current_requirements += linux_requirements
 
-        # Replace run requirements
-        cr_string = '\n    - '.join(current_requirements)
-        meta = re.sub(r'^(requirements:\n(.*\n)+  run:\n)(    .*\n)+',
-                      rf'\g<1>    - {cr_string}\n', meta, flags=re.MULTILINE)
+        self.recipe_clobber.update({
+            "requirements": {"run": current_requirements}
+        })
 
-        # Add version patch
-        meta = re.sub(r'^(source:\n(.*\n)*  patches:\n)',
-                      r'\g<1>    - version.patch\n',
-                      meta, flags=re.MULTILINE)
-        return meta
+        super().patch_recipe()
 
 
 class PylspCondaPkg(BuildCondaPkg):
@@ -334,9 +381,6 @@ class SpyderKernelsCondaPkg(BuildCondaPkg):
     logger.info(f"Building local conda packages {list(args.build)}...")
     t0 = time()
 
-    yaml = YAML()
-    yaml.indent(mapping=2, sequence=4, offset=2)
-
     if SPECS.exists():
         specs = yaml.load(SPECS.read_text())
     else:
diff --git a/installers-conda/build_installers.py b/installers-conda/build_installers.py
--- a/installers-conda/build_installers.py
+++ b/installers-conda/build_installers.py
@@ -30,6 +30,7 @@
 import json
 from logging import getLogger
 import os
+from packaging.version import parse
 from pathlib import Path
 import platform
 import re
@@ -156,13 +157,13 @@
     k, *v = re.split('([<>=]+)[ ]*', spec)
     specs[k] = "".join(v).strip()
 
-PY_VER = re.split('([<>=]+)[ ]*', specs['python'])[-1]
-SPYVER = re.split('([<>=]+)[ ]*', specs['spyder'])[-1]
+PY_VER = parse(re.split('([<>=]+)[ ]*', specs['python'])[-1])
+SPYVER = parse(re.split('([<>=]+)[ ]*', specs['spyder'])[-1])
 
 LOCK_FILE = DIST / f"conda-{TARGET_PLATFORM}.lock"
 TMP_LOCK_FILE = BUILD / f"conda-{TARGET_PLATFORM}.lock"
 OUTPUT_FILE = DIST / f"{APP}-{OS}-{ARCH}.{args.install_type}"
-INSTALLER_DEFAULT_PATH_STEM = f"{APP.lower()}-{SPYVER.split('.')[0]}"
+INSTALLER_DEFAULT_PATH_STEM = f"{APP.lower()}-{SPYVER.major}"
 
 WELCOME_IMG_WIN = BUILD / "welcome_img_win.png"
 HEADER_IMG_WIN = BUILD / "header_img_win.png"
@@ -209,10 +210,14 @@ def _create_conda_lock():
 
 def _patch_conda_lock():
     # Replace local channel url with conda-forge and remove checksum
+    dev_channel = ""
+    if SPYVER.is_prerelease:
+        dev_channel = "/label/spyder_dev"
+
     tmp_text = TMP_LOCK_FILE.read_text()
     text = re.sub(
         f"^{_get_conda_bld_path_url()}(.*)#.*$",
-        r"https://conda.anaconda.org/conda-forge\1",
+        fr"https://conda.anaconda.org/conda-forge{dev_channel}\1",
         tmp_text, flags=re.MULTILINE
     )
     LOCK_FILE.write_text(text)
@@ -291,7 +296,7 @@ def _definitions():
         "name": APP,
         "company": "Spyder-IDE",
         "reverse_domain_identifier": "org.spyder-ide.Spyder",
-        "version": SPYVER,
+        "version": str(SPYVER),
         "channels": [
             "conda-forge/label/spyder_dev",
             "conda-forge/label/spyder_kernels_rc",
@@ -352,7 +357,7 @@ def _definitions():
             (RESOURCES / "osx_pkg_welcome.rtf.tmpl").read_text()
         welcome_file = BUILD / "osx_pkg_welcome.rtf"
         welcome_file.write_text(
-            welcome_text_tmpl.replace("__VERSION__", SPYVER))
+            welcome_text_tmpl.replace("__VERSION__", str(SPYVER)))
 
         definitions.update(
             {


Test patch: