Repository: Ableton/python-pipeline-utils
Pull request #94: Support pyenv on Windows
Author: login: nre-ableton
Created: 2022-06-29T15:53:11Z | Merged: 2025-04-25T10:55:17Z

This PR adds support for Windows in `pyenv.createVirtualEnv()`. I've bumped the minor version since this is technically a breaking change, as these functions previously resulted in an error being thrown when run on Windows.


Diff:
diff --git a/Jenkinsfile b/Jenkinsfile
index ee657098..1ec56d27 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -38,14 +38,11 @@ eventRecorder.timedStage('Integration Test') {
         String venvVersion = venv.run(returnStdout: true, script: 'python --version')
         assert venvVersion.startsWith('Python 3')
 
-        if (isUnix()) {
-          echo 'Test VirtualEnv.createWithPyenv'
-          Object pyvenv = pyenv.createVirtualEnv('3.10.3')
-          String pyvenvVersion =
-            pyvenv.run(returnStdout: true, script: 'python --version')
-          echo pyvenvVersion
-          assert pyvenvVersion.trim() == 'Python 3.10.3'
-        }
+        echo 'Test VirtualEnv.createWithPyenv'
+        Object pyvenv = pyenv.createVirtualEnv('3.10.3')
+        String pyvenvVersion = pyvenv.run(returnStdout: true, script: 'python --version')
+        echo pyvenvVersion
+        assert pyvenvVersion.trim() == 'Python 3.10.3'
       }
     }
   }
diff --git a/VERSION b/VERSION
index 3393b5fd..a5510516 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.14.4
+0.15.0
diff --git a/src/com/ableton/Pyenv.groovy b/src/com/ableton/Pyenv.groovy
index 21ef396c..840e8c56 100644
--- a/src/com/ableton/Pyenv.groovy
+++ b/src/com/ableton/Pyenv.groovy
@@ -23,7 +23,8 @@ class Pyenv implements Serializable {
 
   Pyenv(Object script, String pyenvRoot) {
     this.script = script
-    this.pyenvRoot = pyenvRoot
+    this.pyenvRoot = script.env.OS == 'Windows_NT' ?
+      pyenvRoot.replace('\\', '/') : pyenvRoot
   }
 
   /**
@@ -44,10 +45,6 @@ class Pyenv implements Serializable {
 
     String trimmedPythonVersion = pythonVersion.trim()
 
-    if (script.env.OS == 'Windows_NT') {
-      script.error 'This method is not supported on Windows'
-    }
-
     if (!versionSupported(trimmedPythonVersion)) {
       script.withEnv(["PYENV_ROOT=${pyenvRoot}"]) {
         String pyenvVersion = script.sh(
@@ -62,20 +59,31 @@ class Pyenv implements Serializable {
 
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
     script.retry(INSTALLATION_RETRIES) {
-      List installCommands = [
-        "export PYENV_ROOT=${pyenvRoot}",
-        "export PATH=\$PYENV_ROOT/bin:\$PATH",
-        'eval "\$(pyenv init --path)"',
-        'eval "\$(pyenv init -)"',
-        "pyenv install --skip-existing ${trimmedPythonVersion}",
-        "pyenv shell ${trimmedPythonVersion}",
-        'pip install virtualenv',
-        "virtualenv ${venv.venvRootDir}",
-      ]
-      venv.script.sh(
-        label: "Install Python version ${trimmedPythonVersion} with pyenv",
-        script: installCommands.join('\n') + '\n',
-      )
+      script.withEnv(["PYENV_VERSION=${trimmedPythonVersion}"]) {
+        List installCommands = ["export PYENV_ROOT=${pyenvRoot}"]
+        if (script.env.OS != 'Windows_NT') {
+          installCommands += [
+            "export PATH=\$PYENV_ROOT/bin:\$PATH",
+            'eval "\$(pyenv init --path)"',
+            'eval "\$(pyenv init -)"',
+          ]
+        } else {
+          String posixPyenvRoot = pyenvRoot[1] == ':' ?
+            "/${pyenvRoot[0].toLowerCase()}/${pyenvRoot.substring(3)}" : pyenvRoot
+          installCommands.add(
+            "export PATH=${posixPyenvRoot}/shims:${posixPyenvRoot}/bin:\$PATH"
+          )
+        }
+        installCommands += [
+          "pyenv install --skip-existing ${trimmedPythonVersion}",
+          'pyenv exec pip install virtualenv',
+          "pyenv exec virtualenv ${venv.venvRootDir}",
+        ]
+        venv.script.sh(
+          label: "Install Python version ${trimmedPythonVersion} with pyenv",
+          script: installCommands.join('\n') + '\n',
+        )
+      }
     }
 
     return venv
@@ -93,10 +101,6 @@ class Pyenv implements Serializable {
     assertPyenvRoot()
     boolean result = false
 
-    if (script.env.OS == 'Windows_NT') {
-      script.error 'This method is not supported on Windows'
-    }
-
     script.withEnv(["PYENV_ROOT=${pyenvRoot}"]) {
       String allVersions = script.sh(
         label: 'Get Python versions supported by Pyenv',
diff --git a/test/com/ableton/PyenvTest.groovy b/test/com/ableton/PyenvTest.groovy
index 1da53626..3c9f6b88 100644
--- a/test/com/ableton/PyenvTest.groovy
+++ b/test/com/ableton/PyenvTest.groovy
@@ -101,9 +101,26 @@ class PyenvTest extends BasePipelineTest {
 
   @Test
   void createVirtualEnvWindows() {
+    String pythonVersion = '1.2.3'
+    String pyenvRoot = 'C:\\mock\\pyenv\\root'
+    String posixPyenvRoot = '/c/mock/pyenv/root'
+    String shPyenvRoot = 'C:/mock/pyenv/root'
+    List shMocks = [
+      new Tuple(installCommands(
+        shPyenvRoot, pythonVersion, false, posixPyenvRoot), '', 0
+      ),
+      new Tuple("${shPyenvRoot}/bin/pyenv install --list", '''Available versions:
+  1.2.3
+''', 0),
+    ]
+    shMocks.each { mock -> helper.addShMock(mock[0], mock[1], mock[2]) }
+    helper.registerAllowedMethod('fileExists', [String]) { return true }
     script.env['OS'] = 'Windows_NT'
 
-    assertThrows(Exception) { new Pyenv(script, 'C:\\pyenv').createVirtualEnv('1.2.3') }
+    Object venv = new Pyenv(script, pyenvRoot).createVirtualEnv(pythonVersion, 1)
+
+    assertEquals("/workspace/.venv/${TEST_RANDOM_NAME}" as String, venv.venvRootDir)
+    shMocks.each { mock -> assertCallStackContains(mock[0]) }
   }
 
   @Test
@@ -135,25 +152,51 @@ class PyenvTest extends BasePipelineTest {
 
     assertTrue(new Pyenv(script, pyenvRoot).versionSupported('2.1.3'))
     assertFalse(new Pyenv(script, pyenvRoot).versionSupported('2.1.3333'))
+    assertCallStackContains("${pyenvRoot}/bin/pyenv install --list")
   }
 
   @Test
   void versionSupportedWindows() {
+    // Resembles pyenv's output, at least as of version 2.3.x
+    String mockPyenvVersions = '''Available versions:
+  2.1.3
+  2.2.3
+  2.3.7
+'''
+    String shPyenvRoot = 'C:/pyenv'
+    helper.addShMock("${shPyenvRoot}/bin/pyenv install --list", mockPyenvVersions, 0)
+    helper.registerAllowedMethod('fileExists', [String]) { return true }
     script.env['OS'] = 'Windows_NT'
 
-    assertThrows(Exception) { new Pyenv(script, 'C:\\pyenv').versionSupported('1.2.3') }
+    assertTrue(new Pyenv(script, shPyenvRoot).versionSupported('2.1.3'))
+    assertFalse(new Pyenv(script, shPyenvRoot).versionSupported('2.1.3333'))
+    assertCallStackContains("${shPyenvRoot}/bin/pyenv install --list")
   }
 
-  private String installCommands(String pyenvRoot, String pythonVersion) {
+  private String installCommands(
+    String pyenvRoot,
+    String pythonVersion,
+    boolean isUnix = true,
+    String posixPyenvRoot = null
+  ) {
     List installCommands = [
       "export PYENV_ROOT=${pyenvRoot}",
-      "export PATH=\$PYENV_ROOT/bin:\$PATH",
-      'eval "\$(pyenv init --path)"',
-      'eval "\$(pyenv init -)"',
+    ]
+    if (isUnix) {
+      installCommands += [
+        "export PATH=\$PYENV_ROOT/bin:\$PATH",
+        'eval "\$(pyenv init --path)"',
+        'eval "\$(pyenv init -)"',
+      ]
+    } else {
+      installCommands += [
+        "export PATH=${posixPyenvRoot}/shims:${posixPyenvRoot}/bin:\$PATH",
+      ]
+    }
+    installCommands += [
       "pyenv install --skip-existing ${pythonVersion}",
-      "pyenv shell ${pythonVersion}",
-      'pip install virtualenv',
-      "virtualenv /workspace/.venv/${TEST_RANDOM_NAME}",
+      'pyenv exec pip install virtualenv',
+      "pyenv exec virtualenv /workspace/.venv/${TEST_RANDOM_NAME}",
     ]
 
     return installCommands.join('\n') + '\n'


Reviews:
user: login: ala-ableton
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-07T17:00:52Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-07T17:19:46Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-07T17:22:00Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-07T17:23:25Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-07T17:29:57Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-07T17:31:27Z
user: login: ala-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-07T17:34:03Z
user: login: ala-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-07T17:34:23Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-07T17:42:59Z
user: login: ala-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-07T18:28:49Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-11T11:03:20Z
user: login: ala-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-19T12:05:23Z
user: login: ala-ableton
body: Please fix https://github.com/Ableton/python-pipeline-utils/commit/31ed1db0d80e8133d34fa78d5a06cca5cffa475b and we're almost done.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-19T12:33:18Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-19T12:51:24Z
user: login: ala-ableton
body: In 43d3b35720affb2c67dcf1a97a2e00ba4ef10fa7, we now have `export PATH=...` twice.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-19T13:47:07Z
user: login: ala-ableton
body: Please rebase/squash, then re-rebase and edit 43d3b35720affb2c67dcf1a97a2e00ba4ef10fa7 to make sure that `export PYENV_ROOT=...`  keeps on coming before `export PATH=...`, instead of having `export PATH=...` move around.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-19T17:33:46Z
user: login: ala-ableton
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-19T17:59:32Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2022-07-20T20:39:29Z
user: login: ala-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-21T20:02:53Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-22T14:41:35Z
user: login: ala-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-22T14:43:33Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-22T14:52:48Z
user: login: ala-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-22T14:57:49Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-22T14:59:36Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-22T14:59:40Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-22T14:59:41Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-22T14:59:43Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-22T15:06:28Z
user: login: ala-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-22T15:20:12Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-22T15:21:25Z
user: login: ala-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-22T15:23:24Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-22T15:23:46Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-09-25T07:15:24Z
user: login: ala-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-01-24T17:13:22Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-01-25T07:40:58Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-01-30T12:28:38Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-01-30T12:28:47Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-01-30T12:29:51Z
user: login: ala-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-01-31T13:57:48Z
user: login: ala-ableton
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-13T11:23:55Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-13T13:33:10Z
user: login: ala-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-13T14:02:36Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-13T14:07:11Z
user: login: ala-ableton
body: Please rebase on the latest `main` state and drop the following commits:
- 4b31949270f42e88eed70fa5af4a5992d0761b2c
- 994338699016c75246c9f5ca3360d7a1fb84057d
- ab8dc4d5be8d680f9f1611fb0ecb591d004ac04c
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T08:11:32Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T15:43:09Z
user: login: ala-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T05:57:30Z
user: login: nre-ableton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T10:34:34Z
user: login: ala-ableton
body: Please apply changes similar to a4c566d9a06c5b2aa464fc0e9e4435b0ac735d94 to Windows-specific tests.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T11:37:44Z
user: login: ala-ableton
body: LGTM 👍🏻 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T10:53:53Z

Review comments:
diff_hunk: @@ -48,12 +48,13 @@ class VirtualEnv implements Serializable {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = [
-      "export PYENV_ROOT=${pyenvRoot}",
-      'export PATH=\$PYENV_ROOT/bin:\$PATH',
-      'eval "\$(pyenv init --path)"',
-      'eval "\$(pyenv init -)"',
-    ]
+    List activateCommands = ["export PYENV_ROOT=${pyenvRoot}"]
+    if (!script.env.PYENV_ROOT) {
+      // Only export PYENV_ROOT if it isn't part of the Jenkins environment
+      activateCommands.add("export PYENV_ROOT=${pyenvRoot}")
path: src/com/ableton/VirtualEnv.groovy
user: login: ala-ableton
body: In 9040d9d82b99aabb92b2ecbbf3f7e1357200455a, this doesn't look right. `export PYENV_ROOT=...` will be up to twice in the list and `export PATH=...` won't be there at all.
(It gets somehow fixed in faf391f5df9c7de04923b74ed2d28087f89b7ea5).
created_at: 2022-07-07T16:50:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,24 +37,29 @@ class VirtualEnv implements Serializable {
    * @return New instance of VirtualEnv object.
    */
   static VirtualEnv create(Object script, String python, String pyenvRoot) {
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script)
     assert pyenvRoot
 
     if (!script.fileExists(pyenvRoot)) {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = ["export PYENV_ROOT=${pyenvRoot}"]
+    // Cache for later usage
+    boolean isUnix = script.isUnix()
path: src/com/ableton/VirtualEnv.groovy
user: login: ala-ableton
body: ```suggestion
    boolean isUnix = script.isUnix()  // Cache for later usage
```
to make it clearer it applies to the line and not the block.
created_at: 2022-07-07T16:54:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,24 +37,29 @@ class VirtualEnv implements Serializable {
    * @return New instance of VirtualEnv object.
    */
   static VirtualEnv create(Object script, String python, String pyenvRoot) {
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script)
     assert pyenvRoot
 
     if (!script.fileExists(pyenvRoot)) {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = ["export PYENV_ROOT=${pyenvRoot}"]
+    // Cache for later usage
+    boolean isUnix = script.isUnix()
+    String safePyenvRoot = isUnix ? pyenvRoot : convertWindowsPath(pyenvRoot)
+    List activateCommands = ["export PATH=${safePyenvRoot}/bin:\$PATH"]
     if (!script.env.PYENV_ROOT) {
       // Only export PYENV_ROOT if it isn't part of the Jenkins environment
       activateCommands.add("export PYENV_ROOT=${pyenvRoot}")
     }
-    activateCommands.add('eval "$(pyenv init --path)"')
-    activateCommands.add('eval "$(pyenv init -)"')
+    if (isUnix) {
+      activateCommands.add('eval "$(pyenv init --path)"')
+      activateCommands.add('eval "$(pyenv init -)"')
+    } else {
+      // pyenv on Windows doesn't have the "init" command, so we have to manually add the
path: src/com/ableton/VirtualEnv.groovy
user: login: ala-ableton
body: ```suggestion
      // pyenv-win doesn't have the "init" command, so we have to manually add the
```
created_at: 2022-07-07T16:54:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,34 +37,42 @@ class VirtualEnv implements Serializable {
    * @return New instance of VirtualEnv object.
    */
   static VirtualEnv create(Object script, String python, String pyenvRoot) {
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script)
     assert pyenvRoot
 
     if (!script.fileExists(pyenvRoot)) {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    venv.activateCommands = """
-      export PYENV_ROOT=${pyenvRoot}
-      export PATH=\$PYENV_ROOT/bin:\$PATH
-      eval "\$(pyenv init --path)"
-      eval "\$(pyenv init -)"
-    """
+    // Cache for later usage
+    boolean isUnix = script.isUnix()
+    String safePyenvRoot = isUnix ? pyenvRoot : convertWindowsPath(pyenvRoot)
path: src/com/ableton/VirtualEnv.groovy
user: login: ala-ableton
body: `convertWindowsPath` seems safe to call on Unix paths, so why not rename it to `unixPath`, or something similar, and always call it?
created_at: 2022-07-07T16:58:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,7 +116,7 @@ class VirtualEnv implements Serializable {
       activateSubDir = 'bin'
     } else {
       activateSubDir = 'Scripts'
-      workspace = workspace.replace('\\', '/')
+      workspace = convertWindowsPath(script.env.WORKSPACE)
path: src/com/ableton/VirtualEnv.groovy
user: login: ala-ableton
body: ```suggestion
      workspace = convertWindowsPath(workspace)
```
would better match what was there before.
created_at: 2022-07-07T17:00:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,12 +48,13 @@ class VirtualEnv implements Serializable {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = [
-      "export PYENV_ROOT=${pyenvRoot}",
-      'export PATH=\$PYENV_ROOT/bin:\$PATH',
-      'eval "\$(pyenv init --path)"',
-      'eval "\$(pyenv init -)"',
-    ]
+    List activateCommands = ["export PYENV_ROOT=${pyenvRoot}"]
+    if (!script.env.PYENV_ROOT) {
+      // Only export PYENV_ROOT if it isn't part of the Jenkins environment
+      activateCommands.add("export PYENV_ROOT=${pyenvRoot}")
path: src/com/ableton/VirtualEnv.groovy
user: login: nre-ableton
body: Fixed in ff2c329. Sorry for the force-push.
created_at: 2022-07-07T17:19:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,24 +37,29 @@ class VirtualEnv implements Serializable {
    * @return New instance of VirtualEnv object.
    */
   static VirtualEnv create(Object script, String python, String pyenvRoot) {
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script)
     assert pyenvRoot
 
     if (!script.fileExists(pyenvRoot)) {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = ["export PYENV_ROOT=${pyenvRoot}"]
+    // Cache for later usage
+    boolean isUnix = script.isUnix()
path: src/com/ableton/VirtualEnv.groovy
user: login: nre-ableton
body: Fixed in 002a6cd.
created_at: 2022-07-07T17:22:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,24 +37,29 @@ class VirtualEnv implements Serializable {
    * @return New instance of VirtualEnv object.
    */
   static VirtualEnv create(Object script, String python, String pyenvRoot) {
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script)
     assert pyenvRoot
 
     if (!script.fileExists(pyenvRoot)) {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = ["export PYENV_ROOT=${pyenvRoot}"]
+    // Cache for later usage
+    boolean isUnix = script.isUnix()
+    String safePyenvRoot = isUnix ? pyenvRoot : convertWindowsPath(pyenvRoot)
+    List activateCommands = ["export PATH=${safePyenvRoot}/bin:\$PATH"]
     if (!script.env.PYENV_ROOT) {
       // Only export PYENV_ROOT if it isn't part of the Jenkins environment
       activateCommands.add("export PYENV_ROOT=${pyenvRoot}")
     }
-    activateCommands.add('eval "$(pyenv init --path)"')
-    activateCommands.add('eval "$(pyenv init -)"')
+    if (isUnix) {
+      activateCommands.add('eval "$(pyenv init --path)"')
+      activateCommands.add('eval "$(pyenv init -)"')
+    } else {
+      // pyenv on Windows doesn't have the "init" command, so we have to manually add the
path: src/com/ableton/VirtualEnv.groovy
user: login: nre-ableton
body: Fixed in ed7009d.
created_at: 2022-07-07T17:23:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,34 +37,42 @@ class VirtualEnv implements Serializable {
    * @return New instance of VirtualEnv object.
    */
   static VirtualEnv create(Object script, String python, String pyenvRoot) {
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script)
     assert pyenvRoot
 
     if (!script.fileExists(pyenvRoot)) {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    venv.activateCommands = """
-      export PYENV_ROOT=${pyenvRoot}
-      export PATH=\$PYENV_ROOT/bin:\$PATH
-      eval "\$(pyenv init --path)"
-      eval "\$(pyenv init -)"
-    """
+    // Cache for later usage
+    boolean isUnix = script.isUnix()
+    String safePyenvRoot = isUnix ? pyenvRoot : convertWindowsPath(pyenvRoot)
path: src/com/ableton/VirtualEnv.groovy
user: login: nre-ableton
body: I would prefer `posixPath`. Fixed in d02aa19, 4a2e96e, 649a1cb, and bd05e01614b97edfbd9cfcf15ec2feda84e4f8dd.
created_at: 2022-07-07T17:29:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,7 +116,7 @@ class VirtualEnv implements Serializable {
       activateSubDir = 'bin'
     } else {
       activateSubDir = 'Scripts'
-      workspace = workspace.replace('\\', '/')
+      workspace = convertWindowsPath(script.env.WORKSPACE)
path: src/com/ableton/VirtualEnv.groovy
user: login: nre-ableton
body: Fixed in 9cbcd66.
created_at: 2022-07-07T17:31:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,24 +37,29 @@ class VirtualEnv implements Serializable {
    * @return New instance of VirtualEnv object.
    */
   static VirtualEnv create(Object script, String python, String pyenvRoot) {
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script)
     assert pyenvRoot
 
     if (!script.fileExists(pyenvRoot)) {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = []
+    // Cache for later usage
+    boolean isUnix = script.isUnix()
+    String safePyenvRoot = isUnix ? pyenvRoot : convertWindowsPath(pyenvRoot)
+    List activateCommands = ["export PATH=${safePyenvRoot}/bin:\$PATH"]
path: src/com/ableton/VirtualEnv.groovy
user: login: ala-ableton
body: 8f0b1c8982d53480e14ebfea7cd6ab110e36f289 is still adding back what was missing in ff2c32929787874f7dc9a88c846dc14bfafaf26e.
created_at: 2022-07-07T17:34:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,12 +48,13 @@ class VirtualEnv implements Serializable {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = [
-      "export PYENV_ROOT=${pyenvRoot}",
-      'export PATH=\$PYENV_ROOT/bin:\$PATH',
-      'eval "\$(pyenv init --path)"',
-      'eval "\$(pyenv init -)"',
-    ]
+    List activateCommands = ["export PYENV_ROOT=${pyenvRoot}"]
+    if (!script.env.PYENV_ROOT) {
+      // Only export PYENV_ROOT if it isn't part of the Jenkins environment
+      activateCommands.add("export PYENV_ROOT=${pyenvRoot}")
path: src/com/ableton/VirtualEnv.groovy
user: login: ala-ableton
body: ff2c32929787874f7dc9a88c846dc14bfafaf26e is not complete.
created_at: 2022-07-07T17:34:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,24 +37,29 @@ class VirtualEnv implements Serializable {
    * @return New instance of VirtualEnv object.
    */
   static VirtualEnv create(Object script, String python, String pyenvRoot) {
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script)
     assert pyenvRoot
 
     if (!script.fileExists(pyenvRoot)) {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = []
+    // Cache for later usage
+    boolean isUnix = script.isUnix()
+    String safePyenvRoot = isUnix ? pyenvRoot : convertWindowsPath(pyenvRoot)
+    List activateCommands = ["export PATH=${safePyenvRoot}/bin:\$PATH"]
path: src/com/ableton/VirtualEnv.groovy
user: login: nre-ableton
body: I'm not sure I see it. Maybe I'm missing something obvious? Either way, would it be ok to squash so that there aren't so many fixups cluttering the review?
created_at: 2022-07-07T17:42:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,24 +37,29 @@ class VirtualEnv implements Serializable {
    * @return New instance of VirtualEnv object.
    */
   static VirtualEnv create(Object script, String python, String pyenvRoot) {
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script)
     assert pyenvRoot
 
     if (!script.fileExists(pyenvRoot)) {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = []
+    // Cache for later usage
+    boolean isUnix = script.isUnix()
+    String safePyenvRoot = isUnix ? pyenvRoot : convertWindowsPath(pyenvRoot)
+    List activateCommands = ["export PATH=${safePyenvRoot}/bin:\$PATH"]
path: src/com/ableton/VirtualEnv.groovy
user: login: ala-ableton
body: @nre-ableton sure, feel free to squash whenever you want.
created_at: 2022-07-07T18:28:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,24 +37,29 @@ class VirtualEnv implements Serializable {
    * @return New instance of VirtualEnv object.
    */
   static VirtualEnv create(Object script, String python, String pyenvRoot) {
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script)
     assert pyenvRoot
 
     if (!script.fileExists(pyenvRoot)) {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = []
+    // Cache for later usage
+    boolean isUnix = script.isUnix()
+    String safePyenvRoot = isUnix ? pyenvRoot : convertWindowsPath(pyenvRoot)
+    List activateCommands = ["export PATH=${safePyenvRoot}/bin:\$PATH"]
path: src/com/ableton/VirtualEnv.groovy
user: login: nre-ableton
body: Ok, now that I've squashed and looked at the commits in the branch, I still don't understand this comment. Can you re-review and clarify please?
created_at: 2022-07-11T11:03:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,24 +37,29 @@ class VirtualEnv implements Serializable {
    * @return New instance of VirtualEnv object.
    */
   static VirtualEnv create(Object script, String python, String pyenvRoot) {
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script)
     assert pyenvRoot
 
     if (!script.fileExists(pyenvRoot)) {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = []
+    // Cache for later usage
+    boolean isUnix = script.isUnix()
+    String safePyenvRoot = isUnix ? pyenvRoot : convertWindowsPath(pyenvRoot)
+    List activateCommands = ["export PATH=${safePyenvRoot}/bin:\$PATH"]
path: src/com/ableton/VirtualEnv.groovy
user: login: ala-ableton
body: In 31ed1db0d80e8133d34fa78d5a06cca5cffa475b, the old version had `export PATH=...`, the new version doesn't.
created_at: 2022-07-19T12:05:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,24 +37,29 @@ class VirtualEnv implements Serializable {
    * @return New instance of VirtualEnv object.
    */
   static VirtualEnv create(Object script, String python, String pyenvRoot) {
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script)
     assert pyenvRoot
 
     if (!script.fileExists(pyenvRoot)) {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = []
+    // Cache for later usage
+    boolean isUnix = script.isUnix()
+    String safePyenvRoot = isUnix ? pyenvRoot : convertWindowsPath(pyenvRoot)
+    List activateCommands = ["export PATH=${safePyenvRoot}/bin:\$PATH"]
path: src/com/ableton/VirtualEnv.groovy
user: login: nre-ableton
body: Ah, ok, now I see it. Please see 7812313.
created_at: 2022-07-19T12:51:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,34 +37,41 @@ class VirtualEnv implements Serializable {
    * @return New instance of VirtualEnv object.
    */
   static VirtualEnv create(Object script, String python, String pyenvRoot) {
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script)
     assert pyenvRoot
 
     if (!script.fileExists(pyenvRoot)) {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    venv.activateCommands = """
-      export PYENV_ROOT=${pyenvRoot}
-      export PATH=\$PYENV_ROOT/bin:\$PATH
-      eval "\$(pyenv init --path)"
-      eval "\$(pyenv init -)"
-    """
+    boolean isUnix = script.isUnix()  // Cache for later usage
+    String safePyenvRoot = posixPath(pyenvRoot)
+    List activateCommands = ["export PATH=${safePyenvRoot}/bin:\$PATH"]
+    if (!script.env.PYENV_ROOT) {
+      // Only export PYENV_ROOT if it isn't part of the Jenkins environment
+      activateCommands.add("export PYENV_ROOT=${pyenvRoot}")
path: src/com/ableton/VirtualEnv.groovy
user: login: ala-ableton
body: Why not use `safePyenvRoot` here?
created_at: 2022-07-19T17:31:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,12 +48,13 @@ class VirtualEnv implements Serializable {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = [
-      "export PYENV_ROOT=${pyenvRoot}",
-      'export PATH=\$PYENV_ROOT/bin:\$PATH',
-      'eval "\$(pyenv init --path)"',
-      'eval "\$(pyenv init -)"',
-    ]
+    List activateCommands = ['export PATH=\$PYENV_ROOT/bin:\$PATH']
+    if (!script.env.PYENV_ROOT) {
+      // Only export PYENV_ROOT if it isn't part of the Jenkins environment
+      activateCommands.add("export PYENV_ROOT=${pyenvRoot}")
path: src/com/ableton/VirtualEnv.groovy
user: login: ala-ableton
body: In a4881678645f5b8c3d2c2019cae84850eea7dcdf, please don't put `export PATH=...` before `export PYENV_ROOT=...`
created_at: 2022-07-19T17:59:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,12 +48,13 @@ class VirtualEnv implements Serializable {
       script.error "pyenv root path '${pyenvRoot}' does not exist"
     }
 
-    List activateCommands = [
-      "export PYENV_ROOT=${pyenvRoot}",
-      'export PATH=\$PYENV_ROOT/bin:\$PATH',
-      'eval "\$(pyenv init --path)"',
-      'eval "\$(pyenv init -)"',
-    ]
+    List activateCommands = ['export PATH=\$PYENV_ROOT/bin:\$PATH']
+    if (!script.env.PYENV_ROOT) {
+      // Only export PYENV_ROOT if it isn't part of the Jenkins environment
+      activateCommands.add("export PYENV_ROOT=${pyenvRoot}")
path: src/com/ableton/VirtualEnv.groovy
user: login: nre-ableton
body: I'll take care of this when I'm back from holidays. Also, I merged https://github.com/Ableton/python-pipeline-utils/pull/95 first, and I will rework this PR slightly to take advantage of that.
created_at: 2022-07-20T20:39:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,8 +46,12 @@ class Pyenv implements Serializable {
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
     int result
     script.withEnv(["PYENV_VERSION=${pythonVersion}"]) {
-      List installCommands = [
-        "export PYENV_ROOT=${pyenvRoot}",
+      List installCommands = []
+      if (!script.env.PYENV_ROOT) {
+        // Only export PYENV_ROOT if it isn't part of the Jenkins environment
+        installCommands.add("export PYENV_ROOT=${pyenvRoot}")
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: This change looks weird. What is the point of having a `pyenvRoot` on `pyenv.createVirtualEnv` and `pyenv.versionSupported` if it might get ignored?
Either `com.ableton.Pyenv` requires the `PYENV_ROOT` env var to be set, or it sets it itself, but a mix of both is a recipe for confusion.
created_at: 2023-09-21T19:54:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -70,6 +70,14 @@ class VirtualEnv implements Serializable {
     this.venvBinDir = "${venvRootDir}/${activateSubDir}"
   }
 
+  @NonCPS
+  static final String posixPath(String path) {
+    if (path[1] == ':') {
+      return "/${path[0].toLowerCase()}/${path.substring(3).replaceAll('\\\\', '/')}"
+    }
+    return path
path: src/com/ableton/VirtualEnv.groovy
user: login: ala-ableton
body: It seems that this function doesn't handle relative paths on Windows. That's a bit weird. Should it assert that the given path is absolute? Or make it absolute first?
created_at: 2023-09-21T19:55:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -52,7 +52,7 @@ class Pyenv implements Serializable {
         installCommands.add("export PYENV_ROOT=${pyenvRoot}")
       }
       installCommands += [
-        "export PATH=\$PYENV_ROOT/bin:\$PATH",
+        "export PATH=${VirtualEnv.posixPath(pyenvRoot)}/bin:\$PATH",
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: Why does 9126435d25a726424bea86cc47123681b6ff404e convert `pyenvRoot` to a POSIX path on line 55 but not on line 52?
created_at: 2023-09-21T19:58:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,7 +10,9 @@ import com.ableton.VirtualEnv
  * @return New {@link com.ableton.VirtualEnv} instance.
  */
 VirtualEnv createVirtualEnv(String pythonVersion, String pyenvRoot = null) {
-  return new Pyenv(this, pyenvRoot ?: env.PYENV_ROOT).createVirtualEnv(pythonVersion)
+  return new Pyenv(
+    this, VirtualEnv.posixPath(pyenvRoot ?: env.PYENV_ROOT)
path: vars/pyenv.groovy
user: login: ala-ableton
body: Why do 2 conversions? (one here and one in `Pyenv.createVirtualEnv`). Wouldn't it be simpler to do a single conversion in the constructor of the `Pyenv` class?
created_at: 2023-09-21T20:00:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,8 +120,8 @@ class Pyenv implements Serializable {
   protected void assertPyenvRoot() {
     assert pyenvRoot
 
-    if (!script.fileExists(pyenvRoot)) {
-      script.error "pyenv root path '${pyenvRoot}' does not exist"
+    if (script.sh(returnStatus: true, script: "${pyenvRoot}/bin/pyenv --version")) {
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: So `script.dir` is fine with POSIX paths on Windows, but not `script.fileExists`... That's strange 🤔 
created_at: 2023-09-21T20:01:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,10 +47,14 @@ class Pyenv implements Serializable {
         // Only export PYENV_ROOT if it isn't part of the Jenkins environment
         installCommands.add("export PYENV_ROOT=${pyenvRoot}")
       }
+      installCommands.add("export PATH=${VirtualEnv.posixPath(pyenvRoot)}/bin:\$PATH")
+      if (script.isUnix()) {
+        installCommands += [
+          "eval \"\$(pyenv init --path)\"",
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: `eval "$(pyenv init --path)"` prepends `$PYENV_ROOT/shims` to the `PATH` env var. Isn't there a `shims` folder on Windows?
created_at: 2023-09-21T20:02:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,8 +46,12 @@ class Pyenv implements Serializable {
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
     int result
     script.withEnv(["PYENV_VERSION=${pythonVersion}"]) {
-      List installCommands = [
-        "export PYENV_ROOT=${pyenvRoot}",
+      List installCommands = []
+      if (!script.env.PYENV_ROOT) {
+        // Only export PYENV_ROOT if it isn't part of the Jenkins environment
+        installCommands.add("export PYENV_ROOT=${pyenvRoot}")
path: src/com/ableton/Pyenv.groovy
user: login: nre-ableton
body: I'm not sure I follow you here. Why do you say, "if it might get ignored"? If `pyenvRoot` is added as the optional argument to either function in the `pyenv` singleton, it'll be set here, not ignored.

The purpose of this line is to just prevent exporting something which is already in the environment.
created_at: 2023-09-22T14:41:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,8 +46,12 @@ class Pyenv implements Serializable {
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
     int result
     script.withEnv(["PYENV_VERSION=${pythonVersion}"]) {
-      List installCommands = [
-        "export PYENV_ROOT=${pyenvRoot}",
+      List installCommands = []
+      if (!script.env.PYENV_ROOT) {
+        // Only export PYENV_ROOT if it isn't part of the Jenkins environment
+        installCommands.add("export PYENV_ROOT=${pyenvRoot}")
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: The `pyenvRoot` field won't be used here if `script.env.PYENV_ROOT` is true-ish. That's what I mean by "ignored".
created_at: 2023-09-22T14:43:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,8 +120,8 @@ class Pyenv implements Serializable {
   protected void assertPyenvRoot() {
     assert pyenvRoot
 
-    if (!script.fileExists(pyenvRoot)) {
-      script.error "pyenv root path '${pyenvRoot}' does not exist"
+    if (script.sh(returnStatus: true, script: "${pyenvRoot}/bin/pyenv --version")) {
path: src/com/ableton/Pyenv.groovy
user: login: nre-ableton
body: No, neither one of them works. The problem is with *absolute, CygWin-style paths*. I just ran this code in the sandbox:

```groovy
node('generic-win') {
    dir('C:\\Windows\\Temp') {
        bat 'dir'
    }
    if (fileExists('C:\\Windows\\Temp\\vmware-vmusr-ci.log')) {
        echo 'it is there'
    } else {
        echo 'it is not there'
    }
    
    dir('/c/windows/temp') {
        bat 'dir'
    }
    if (fileExists('/c/windows/temp/vmware-vmusr-ci.log')) {
        echo 'it is also there'
    } else {
        echo 'it is also not there'
    }
}
```

Here's the result:

```
Running on [generic-win-vs2019-00](https://newci.office.ableton.com/computer/generic%2Dwin%2Dvs2019%2D00/) in F:\workspace\Utilities\sandbox
 [Pipeline] {
 [Pipeline] dir
 Running in C:\Windows\Temp
 [Pipeline] {
 [Pipeline] bat
 
 C:\Windows\Temp>dir
  Volume in drive C has no label.
  Volume Serial Number is 4CEB-A7DB
 
  Directory of C:\Windows\Temp
 
 22/09/2023  13:29    <DIR>          .
 22/09/2023  13:29    <DIR>          ..
 17/09/2023  07:46         3.788.840 f03139a7-0df9-4c0e-8fae-a95623bd1935.tmp
 27/07/2023  13:23                 0 FXSAPIDebugLogFile.txt
 27/07/2023  13:23                 0 FXSTIFFDebugLogFile.txt
 22/09/2023  13:31           931.062 MpCmdRun.log
 22/09/2023  12:08           957.232 MpSigStub.log
 22/09/2023  09:46           517.550 msedge_installer.log
 20/09/2023  09:51    <DIR>          vmware-SYSTEM
 14/09/2023  23:19           345.309 vmware-vmsvc-SYSTEM.log
 26/07/2023  11:29            44.173 vmware-vmusr-ci.log
 14/09/2023  21:33    <DIR>          WinSAT
 14/09/2023  14:34    <DIR>          {5ED6833A-C365-4743-9B64-12A039EC1E06}
                8 File(s)      6.584.166 bytes
                5 Dir(s)  15.565.709.312 bytes free
 [Pipeline] }
 [Pipeline] // dir
 [Pipeline] fileExists
 [Pipeline] echo
 it is there
 [Pipeline] dir
 Running in /c/windows/temp
 [Pipeline] {
 [Pipeline] bat
 
 F:\c\windows\temp>dir
  Volume in drive F has no label.
  Volume Serial Number is 0C9E-D458
 
  Directory of F:\c\windows\temp
 
 22/09/2023  14:49    <DIR>          .
 22/09/2023  14:49    <DIR>          ..
                0 File(s)              0 bytes
                2 Dir(s)  20.222.361.600 bytes free
 [Pipeline] }
 [Pipeline] // dir
 [Pipeline] fileExists
 [Pipeline] echo
 it is also not there
```

It seems that both `dir` and `fileExists` don't interpret `/c` as being `C:\` and try to run it under the current drive, `F:\`. But the `sh` step will work since that (presumably) uses a git bash shell, which does understand CygWin paths.
created_at: 2023-09-22T14:52:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,8 +120,8 @@ class Pyenv implements Serializable {
   protected void assertPyenvRoot() {
     assert pyenvRoot
 
-    if (!script.fileExists(pyenvRoot)) {
-      script.error "pyenv root path '${pyenvRoot}' does not exist"
+    if (script.sh(returnStatus: true, script: "${pyenvRoot}/bin/pyenv --version")) {
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: > neither one of them works.

Then this means that https://github.com/Ableton/python-pipeline-utils/pull/94/commits/59e794c124a1aff0c263c95c9255d5ef97509bc2 breaks running `VirtualEnv.cleanup` on Windows.
created_at: 2023-09-22T14:57:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,10 +47,14 @@ class Pyenv implements Serializable {
         // Only export PYENV_ROOT if it isn't part of the Jenkins environment
         installCommands.add("export PYENV_ROOT=${pyenvRoot}")
       }
+      installCommands.add("export PATH=${VirtualEnv.posixPath(pyenvRoot)}/bin:\$PATH")
+      if (script.isUnix()) {
+        installCommands += [
+          "eval \"\$(pyenv init --path)\"",
path: src/com/ableton/Pyenv.groovy
user: login: nre-ableton
body: Looking at the production node that ran this CI job, it seems so. It's in `F:\pyenv-win\pyenv-win\shims`. Fixed in 24f6bf9.
created_at: 2023-09-22T14:59:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,7 +10,9 @@ import com.ableton.VirtualEnv
  * @return New {@link com.ableton.VirtualEnv} instance.
  */
 VirtualEnv createVirtualEnv(String pythonVersion, String pyenvRoot = null) {
-  return new Pyenv(this, pyenvRoot ?: env.PYENV_ROOT).createVirtualEnv(pythonVersion)
+  return new Pyenv(
+    this, VirtualEnv.posixPath(pyenvRoot ?: env.PYENV_ROOT)
path: vars/pyenv.groovy
user: login: nre-ableton
body: I was trying to avoid doing too much work in the ctor. But I agree it is probably better in this case compared to the code duplication. Fixed in 735ff99.
created_at: 2023-09-22T14:59:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -52,7 +52,7 @@ class Pyenv implements Serializable {
         installCommands.add("export PYENV_ROOT=${pyenvRoot}")
       }
       installCommands += [
-        "export PATH=\$PYENV_ROOT/bin:\$PATH",
+        "export PATH=${VirtualEnv.posixPath(pyenvRoot)}/bin:\$PATH",
path: src/com/ableton/Pyenv.groovy
user: login: nre-ableton
body: I guess we should do both. Fixed in 34f1963.
created_at: 2023-09-22T14:59:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -70,6 +70,14 @@ class VirtualEnv implements Serializable {
     this.venvBinDir = "${venvRootDir}/${activateSubDir}"
   }
 
+  @NonCPS
+  static final String posixPath(String path) {
+    if (path[1] == ':') {
+      return "/${path[0].toLowerCase()}/${path.substring(3).replaceAll('\\\\', '/')}"
+    }
+    return path
path: src/com/ableton/VirtualEnv.groovy
user: login: nre-ableton
body: We can easily support relative paths. Fixed in 65a8eea.
created_at: 2023-09-22T14:59:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,8 +46,12 @@ class Pyenv implements Serializable {
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
     int result
     script.withEnv(["PYENV_VERSION=${pythonVersion}"]) {
-      List installCommands = [
-        "export PYENV_ROOT=${pyenvRoot}",
+      List installCommands = []
+      if (!script.env.PYENV_ROOT) {
+        // Only export PYENV_ROOT if it isn't part of the Jenkins environment
+        installCommands.add("export PYENV_ROOT=${pyenvRoot}")
path: src/com/ableton/Pyenv.groovy
user: login: nre-ableton
body: I think that the idea here was that we didn't want to have "conflicting" values, but come to think of it, maybe we _do_ always want to export `PYENV_ROOT` because we have determined the "safe" value. Fixed in 2d0d2c7 (which means we'll drop that commit).
created_at: 2023-09-22T15:06:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,25 +39,32 @@ class Pyenv implements Serializable {
     assert pythonVersion
     assertPyenvRoot()
 
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
-    int result = venv.script.sh(
-      label: "Install Python version ${pythonVersion} with pyenv",
-      returnStatus: true,
-      script: """
-        export PYENV_ROOT=${pyenvRoot}
-        export PATH=\$PYENV_ROOT/bin:\$PATH
-        eval "\$(pyenv init --path)"
-        eval "\$(pyenv init -)"
-        pyenv install --skip-existing ${pythonVersion}
-        pyenv shell ${pythonVersion}
-        pip install virtualenv
-        virtualenv ${venv.venvRootDir}
-      """,
-    )
+    int result
+    script.withEnv(["PYENV_VERSION=${pythonVersion}"]) {
+      List installCommands = []
+      String safePyenvRoot = VirtualEnv.posixPath(pyenvRoot)
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: `pyenvRoot` should already be "safe", since it now goes through `VirtualEnv.posixPath` in the constructor of the `Pyenv` class.
created_at: 2023-09-22T15:20:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,25 +39,32 @@ class Pyenv implements Serializable {
     assert pythonVersion
     assertPyenvRoot()
 
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
-    int result = venv.script.sh(
-      label: "Install Python version ${pythonVersion} with pyenv",
-      returnStatus: true,
-      script: """
-        export PYENV_ROOT=${pyenvRoot}
-        export PATH=\$PYENV_ROOT/bin:\$PATH
-        eval "\$(pyenv init --path)"
-        eval "\$(pyenv init -)"
-        pyenv install --skip-existing ${pythonVersion}
-        pyenv shell ${pythonVersion}
-        pip install virtualenv
-        virtualenv ${venv.venvRootDir}
-      """,
-    )
+    int result
+    script.withEnv(["PYENV_VERSION=${pythonVersion}"]) {
+      List installCommands = []
+      String safePyenvRoot = VirtualEnv.posixPath(pyenvRoot)
path: src/com/ableton/Pyenv.groovy
user: login: nre-ableton
body: But it's not "safe" in the existing environment. For instance, on our CI nodes, it is exported in the environment to a Windows path.
created_at: 2023-09-22T15:21:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,25 +39,32 @@ class Pyenv implements Serializable {
     assert pythonVersion
     assertPyenvRoot()
 
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
-    int result = venv.script.sh(
-      label: "Install Python version ${pythonVersion} with pyenv",
-      returnStatus: true,
-      script: """
-        export PYENV_ROOT=${pyenvRoot}
-        export PATH=\$PYENV_ROOT/bin:\$PATH
-        eval "\$(pyenv init --path)"
-        eval "\$(pyenv init -)"
-        pyenv install --skip-existing ${pythonVersion}
-        pyenv shell ${pythonVersion}
-        pip install virtualenv
-        virtualenv ${venv.venvRootDir}
-      """,
-    )
+    int result
+    script.withEnv(["PYENV_VERSION=${pythonVersion}"]) {
+      List installCommands = []
+      String safePyenvRoot = VirtualEnv.posixPath(pyenvRoot)
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: I'm talking about the `pyenvRoot` field. Right here. `this.pyenvRoot` if you prefer.
created_at: 2023-09-22T15:23:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -120,8 +120,8 @@ class Pyenv implements Serializable {
   protected void assertPyenvRoot() {
     assert pyenvRoot
 
-    if (!script.fileExists(pyenvRoot)) {
-      script.error "pyenv root path '${pyenvRoot}' does not exist"
+    if (script.sh(returnStatus: true, script: "${pyenvRoot}/bin/pyenv --version")) {
path: src/com/ableton/Pyenv.groovy
user: login: nre-ableton
body: Good catch. Fixed in 37fc6a4.
created_at: 2023-09-22T15:23:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,25 +39,32 @@ class Pyenv implements Serializable {
     assert pythonVersion
     assertPyenvRoot()
 
-    if (!script.isUnix()) {
-      script.error 'This method is not supported on Windows'
-    }
-
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
-    int result = venv.script.sh(
-      label: "Install Python version ${pythonVersion} with pyenv",
-      returnStatus: true,
-      script: """
-        export PYENV_ROOT=${pyenvRoot}
-        export PATH=\$PYENV_ROOT/bin:\$PATH
-        eval "\$(pyenv init --path)"
-        eval "\$(pyenv init -)"
-        pyenv install --skip-existing ${pythonVersion}
-        pyenv shell ${pythonVersion}
-        pip install virtualenv
-        virtualenv ${venv.venvRootDir}
-      """,
-    )
+    int result
+    script.withEnv(["PYENV_VERSION=${pythonVersion}"]) {
+      List installCommands = []
+      String safePyenvRoot = VirtualEnv.posixPath(pyenvRoot)
path: src/com/ableton/Pyenv.groovy
user: login: nre-ableton
body: Fixed in 5d4b875.
created_at: 2023-09-25T07:15:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -44,19 +44,21 @@ class Pyenv implements Serializable {
     }
 
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
-    int result = venv.script.sh(
+    int result
+    List installCommands = [
+      "export PYENV_ROOT=${pyenvRoot}",
+      "export PATH=\$PYENV_ROOT/bin:\$PATH",
+      "eval \"\$(pyenv init --path)\"",
+      "eval \"\$(pyenv init -)\"",
+      "pyenv install --skip-existing ${pythonVersion}",
+      "pyenv shell ${pythonVersion}",
+      'pip install virtualenv',
+      "virtualenv ${venv.venvRootDir}",
+    ]
+    result = venv.script.sh(
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: In de32499621186c7c7d63e2bac97f5c8bcf46e265, I don't see why `result` gets defined before `installCommands`.
created_at: 2024-01-24T17:02:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -45,21 +45,22 @@ class Pyenv implements Serializable {
 
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
     int result
-    List installCommands = [
-      "export PYENV_ROOT=${pyenvRoot}",
-      "export PATH=\$PYENV_ROOT/bin:\$PATH",
-      "eval \"\$(pyenv init --path)\"",
-      "eval \"\$(pyenv init -)\"",
-      "pyenv install --skip-existing ${pythonVersion}",
-      "pyenv shell ${pythonVersion}",
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: pyenv-win supports `pyenv shell`, so I'm not sure why we can't use it on Windows.
created_at: 2024-01-24T17:07:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,7 +48,7 @@ class Pyenv implements Serializable {
     script.withEnv(["PYENV_VERSION=${pythonVersion}"]) {
       List installCommands = [
         "export PYENV_ROOT=${pyenvRoot}",
-        "export PATH=\$PYENV_ROOT/bin:\$PATH",
+        "export PATH=${pyenvRoot}/bin:\$PATH",
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: In 2a9caf3ab781cbc9575271a6a96b65314ebcd2a3, I don't see why we need to change this line.
created_at: 2024-01-24T17:10:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -45,21 +45,22 @@ class Pyenv implements Serializable {
 
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
     int result
-    List installCommands = [
-      "export PYENV_ROOT=${pyenvRoot}",
-      "export PATH=\$PYENV_ROOT/bin:\$PATH",
-      "eval \"\$(pyenv init --path)\"",
-      "eval \"\$(pyenv init -)\"",
-      "pyenv install --skip-existing ${pythonVersion}",
-      "pyenv shell ${pythonVersion}",
path: src/com/ableton/Pyenv.groovy
user: login: nre-ableton
body: IIRC, when I last tested this, it didn't work for us. I'll try it again, though.
created_at: 2024-01-25T07:40:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,7 +48,7 @@ class Pyenv implements Serializable {
     script.withEnv(["PYENV_VERSION=${pythonVersion}"]) {
       List installCommands = [
         "export PYENV_ROOT=${pyenvRoot}",
-        "export PATH=\$PYENV_ROOT/bin:\$PATH",
+        "export PATH=${pyenvRoot}/bin:\$PATH",
path: src/com/ableton/Pyenv.groovy
user: login: nre-ableton
body: See 4270827.
created_at: 2024-01-30T12:28:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -44,19 +44,21 @@ class Pyenv implements Serializable {
     }
 
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
-    int result = venv.script.sh(
+    int result
+    List installCommands = [
+      "export PYENV_ROOT=${pyenvRoot}",
+      "export PATH=\$PYENV_ROOT/bin:\$PATH",
+      "eval \"\$(pyenv init --path)\"",
+      "eval \"\$(pyenv init -)\"",
+      "pyenv install --skip-existing ${pythonVersion}",
+      "pyenv shell ${pythonVersion}",
+      'pip install virtualenv',
+      "virtualenv ${venv.venvRootDir}",
+    ]
+    result = venv.script.sh(
path: src/com/ableton/Pyenv.groovy
user: login: nre-ableton
body: See 14fbba1.
created_at: 2024-01-30T12:28:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -45,21 +45,22 @@ class Pyenv implements Serializable {
 
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
     int result
-    List installCommands = [
-      "export PYENV_ROOT=${pyenvRoot}",
-      "export PATH=\$PYENV_ROOT/bin:\$PATH",
-      "eval \"\$(pyenv init --path)\"",
-      "eval \"\$(pyenv init -)\"",
-      "pyenv install --skip-existing ${pythonVersion}",
-      "pyenv shell ${pythonVersion}",
path: src/com/ableton/Pyenv.groovy
user: login: nre-ableton
body: I just pushed a WIP commit to test this, and indeed, it failed. So while it is supported, it doesn't seem to work from Jenkins for the shell session.
created_at: 2024-01-30T12:29:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -45,21 +45,22 @@ class Pyenv implements Serializable {
 
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
     int result
-    List installCommands = [
-      "export PYENV_ROOT=${pyenvRoot}",
-      "export PATH=\$PYENV_ROOT/bin:\$PATH",
-      "eval \"\$(pyenv init --path)\"",
-      "eval \"\$(pyenv init -)\"",
-      "pyenv install --skip-existing ${pythonVersion}",
-      "pyenv shell ${pythonVersion}",
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: That's a shame... Well, I guess setting the `PYTHON_VERSION` env var is not that bad.
created_at: 2024-01-31T13:57:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -35,6 +35,8 @@ eventRecorder.timedStage('Integration Test') {
         nodeLabel = 'generic-mac-xcode12.2'
       }
       eventRecorder.timedNode(nodeLabel) {
+        sh 'env | sort'
path: Jenkinsfile
user: login: ala-ableton
body: a2daa8a1910cfc24cccecaa806dd4260534e6521 should be dropped.
created_at: 2025-02-13T10:25:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -62,18 +62,19 @@ class Pyenv implements Serializable {
 
     VirtualEnv venv = new VirtualEnv(script, randomSeed)
     script.retry(INSTALLATION_RETRIES) {
+      List installCommands = [
+        "export PYENV_ROOT=${pyenvRoot}",
+        "export PATH=\$PYENV_ROOT/bin:\$PATH",
+        "eval \"\$(pyenv init --path)\"",
+        "eval \"\$(pyenv init -)\"",
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: ```suggestion
        'eval "\$(pyenv init --path)"',
        'eval "\$(pyenv init -)"',
```
created_at: 2025-02-13T10:27:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -166,16 +166,17 @@ class PyenvTest extends BasePipelineTest {
   }
 
   private String installCommands(String pyenvRoot, String pythonVersion) {
-    // Indentation must match the actual command
-    return """
-          export PYENV_ROOT=${pyenvRoot}
-          export PATH=\$PYENV_ROOT/bin:\$PATH
-          eval "\$(pyenv init --path)"
-          eval "\$(pyenv init -)"
-          pyenv install --skip-existing ${pythonVersion}
-          pyenv shell ${pythonVersion}
-          pip install virtualenv
-          virtualenv /workspace/.venv/${TEST_RANDOM_NAME}
-      """
+    List installCommands = [
+      "export PYENV_ROOT=${pyenvRoot}",
+      "export PATH=\$PYENV_ROOT/bin:\$PATH",
+      "eval \"\$(pyenv init --path)\"",
+      "eval \"\$(pyenv init -)\"",
path: test/com/ableton/PyenvTest.groovy
user: login: ala-ableton
body: ```suggestion
      'eval "\$(pyenv init --path)"',
      'eval "\$(pyenv init -)"',
```
created_at: 2025-02-13T10:28:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -70,6 +70,14 @@ class VirtualEnv implements Serializable {
     this.venvBinDir = "${venvRootDir}/${activateSubDir}"
   }
 
+  @NonCPS
+  static final String posixPath(String path) {
path: src/com/ableton/VirtualEnv.groovy
user: login: ala-ableton
body: Does it really need to be annotated with `@NonCPS`?
created_at: 2025-02-13T11:22:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -70,6 +70,14 @@ class VirtualEnv implements Serializable {
     this.venvBinDir = "${venvRootDir}/${activateSubDir}"
   }
 
+  @NonCPS
+  static final String posixPath(String path) {
path: src/com/ableton/VirtualEnv.groovy
user: login: nre-ableton
body: Probably not, but why not? It is non-CPS-compatible, and we already need to import that symbol for `randomName`.
created_at: 2025-02-13T13:33:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -70,6 +70,14 @@ class VirtualEnv implements Serializable {
     this.venvBinDir = "${venvRootDir}/${activateSubDir}"
   }
 
+  @NonCPS
+  static final String posixPath(String path) {
path: src/com/ableton/VirtualEnv.groovy
user: login: ala-ableton
body: I guess you misunderstood my question.
```suggestion
  static final String posixPath(String path) {
```
created_at: 2025-02-13T14:02:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -70,6 +70,14 @@ class VirtualEnv implements Serializable {
     this.venvBinDir = "${venvRootDir}/${activateSubDir}"
   }
 
+  @NonCPS
+  static final String posixPath(String path) {
path: src/com/ableton/VirtualEnv.groovy
user: login: nre-ableton
body: No, I understood your question correctly. As the function does not use any pipeline steps, it should ideally be annotated with `@NonCPS` in order to optimize Jenkins' performance.
created_at: 2025-02-13T14:07:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,7 +23,7 @@ class Pyenv implements Serializable {
 
   Pyenv(Object script, String pyenvRoot) {
     this.script = script
-    this.pyenvRoot = pyenvRoot
+    this.pyenvRoot = pyenvRoot ? VirtualEnv.posixPath(pyenvRoot) : null
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: Fixup for f93cbac65f60334e86e93d5a959ef386103fbd09 (whose commit message should also be reworded)
```suggestion
    this.pyenvRoot =
      script.env.OS == 'Windows_NT' ? pyenvRoot.replace('\\', '/') : pyenvRoot
```
It might actually make sense to simplify squash that change into "Support pyenv on Windows".
created_at: 2025-04-10T08:07:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,7 +23,7 @@ class Pyenv implements Serializable {
 
   Pyenv(Object script, String pyenvRoot) {
     this.script = script
-    this.pyenvRoot = pyenvRoot
+    this.pyenvRoot = pyenvRoot ? VirtualEnv.posixPath(pyenvRoot) : null
path: src/com/ableton/Pyenv.groovy
user: login: nre-ableton
body: Let's just squash it.
created_at: 2025-04-11T15:43:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,7 +23,7 @@ class Pyenv implements Serializable {
 
   Pyenv(Object script, String pyenvRoot) {
     this.script = script
-    this.pyenvRoot = pyenvRoot
+    this.pyenvRoot = pyenvRoot ? VirtualEnv.posixPath(pyenvRoot) : null
path: src/com/ableton/Pyenv.groovy
user: login: ala-ableton
body: Fixup for b82526ed0a66ae10ef963fd10d196f7c0f5ddf5e
```suggestion
    this.pyenvRoot =
      script.env.OS == 'Windows_NT' ? pyenvRoot.replace('\\', '/') : pyenvRoot
```
created_at: 2025-04-14T05:57:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,7 +23,7 @@ class Pyenv implements Serializable {
 
   Pyenv(Object script, String pyenvRoot) {
     this.script = script
-    this.pyenvRoot = pyenvRoot
+    this.pyenvRoot = pyenvRoot ? VirtualEnv.posixPath(pyenvRoot) : null
path: src/com/ableton/Pyenv.groovy
user: login: nre-ableton
body: I don't think this is going to work, and we probably need to re-cherry-pick https://github.com/Ableton/python-pipeline-utils/commit/4b31949270f42e88eed70fa5af4a5992d0761b2c and friends. Although the unit tests pass with their mocks, it fails when running on a Windows host with a `pyenv: command not found` error. Have a look at the `PATH` that we create:

> `PATH='F:/pyenv-win/pyenv-win/shims:F:/pyenv-win/pyenv-win/bin:/c/Python27:/c/Python27/Scripts:/c/Program Files/Eclipse Adoptium/jdk-21.0.3.9-hotspot/bin:/c/WINDOWS/system32:/c/WINDOWS:/c/WINDOWS/System32/Wbem:/c/WINDOWS/System32/WindowsPowerShell/v1.0:/c/ProgramData/chocolatey/bin:/cmd:/mingw64/bin:/usr/bin:/c/Program Files/LLVM/bin:/c/Program Files/Git LFS:/c/Python37:/c/Python37/Scripts:/c/Python310:/c/Python310/Scripts:/c/Program Files/CMake/bin:/c/Users/ci/AppData/Local/Microsoft/WindowsApps'`

I'm pretty sure that these path elements need to be cygwin-compatible paths like `/c/Windows/whatever`.

It might be easier to try to debug this over a huddle sometime.
created_at: 2025-04-14T10:34:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -101,9 +101,20 @@ class PyenvTest extends BasePipelineTest {
 
   @Test
   void createVirtualEnvWindows() {
+    String pythonVersion = '1.2.3'
+    String pyenvRoot = 'C:\\mock\\pyenv\\root'
+    String cygwinPyenvRoot = 'C:/mock/pyenv/root'
+    helper.addShMock("${cygwinPyenvRoot}/bin/pyenv --version", 'pyenv 1.2.3', 0)
+    helper.addShMock("${cygwinPyenvRoot}/bin/pyenv install --list", '''Available versions:
+  1.2.3
+''', 0)
+    helper.addShMock(installCommands(cygwinPyenvRoot, pythonVersion, false), '', 0)
path: test/com/ableton/PyenvTest.groovy
user: login: ala-ableton
body: That mock is not called. 
created_at: 2025-04-24T11:37:19Z
author_association: CONTRIBUTOR

PR comments:
user: login: nre-ableton
created_at: 2022-07-07T17:32:11Z
body: @ala-ableton my changes _should_ squash cleanly. Let me know when I should squash.
author_association: CONTRIBUTOR
user: login: nre-ableton
created_at: 2022-07-19T17:07:29Z
body: > In 43d3b35720affb2c67dcf1a97a2e00ba4ef10fa7, we now have `export PATH=...` twice.

Sorry about that. Fixed in b5b1c96bd326dd56133e2b8a21364665d93e8d3a.
author_association: CONTRIBUTOR
user: login: nre-ableton
created_at: 2022-07-28T13:51:40Z
body: So I've rebased this PR, and also added Windows support for the newly-added integration tests, but they keep timing out after 1 hour.

The first time, the build failed with a different error (not a timeout):

```
[2022-07-28T08:33:57.000Z] F:/jenkins-node/workspace@tmp/durable-6cb19c97/script.sh: line 4: /f/jenkins-node/workspace/.venv/venv-95957963/Scripts/activate: No such file or directory
```

I've no idea why this is happening. :disappointed: 
author_association: CONTRIBUTOR
user: login: nre-ableton
created_at: 2023-09-20T11:09:05Z
body: Ok! This PR is finally ready for review, since our CI nodes now have the updated version of Pyenv needed for Windows support.
author_association: CONTRIBUTOR
user: login: nre-ableton
created_at: 2023-09-25T07:15:32Z
body: @ala-ableton Please let me know when I should rebase.
author_association: CONTRIBUTOR
user: login: ala-ableton
created_at: 2023-09-25T14:11:43Z
body: @nre-ableton Feel free to rebase whenever you want.
author_association: CONTRIBUTOR
user: login: nre-ableton
created_at: 2023-09-25T14:43:43Z
body: > @nre-ableton Feel free to rebase whenever you want.

Done. Feel free to review. 😊 Note that I pushed a dropme commit just to make sure that installing fresh (uncached) Python versions still works.
author_association: CONTRIBUTOR
user: login: nre-ableton
created_at: 2024-01-31T14:10:04Z
body: Forgive the rebase/force-push, I just noticed a vim-related typo in a commit message and decided to just drop the experiment commits regarding `pyenv shell`.
author_association: CONTRIBUTOR
user: login: nre-ableton
created_at: 2024-11-29T10:36:15Z
body: @ala-ableton I've rebased this PR, ptal!
author_association: CONTRIBUTOR
user: login: nre-ableton
created_at: 2025-04-11T16:01:35Z
body: I've rebased and edited the history as requested, but there are some failing tests that I will investigate next week. I will re-request a review once these have been fixed.
author_association: CONTRIBUTOR
user: login: nre-ableton
created_at: 2025-04-16T20:30:29Z
body: @ala-ableton I fixed the issues with the tests, but I think that a preparation PR would be better for all the non-Windows-specific stuff. So I've opened up https://github.com/Ableton/python-pipeline-utils/pull/164, which I assume you'll be interested in reviewing.
author_association: CONTRIBUTOR
user: login: nre-ableton
created_at: 2025-04-25T10:44:50Z
body: @ala-ableton I’m ok with your fixup, so I’ll go ahead and squash
author_association: CONTRIBUTOR
user: login: nre-ableton
created_at: 2025-04-25T10:45:00Z
body: @ablbot autosquash 
author_association: CONTRIBUTOR
user: login: ableton-ablbot[bot]
created_at: 2025-04-25T10:45:06Z
body: Rebase job `dfe5d703-6e82-417a-874b-6c2bd6cb89c0` has been created. There are currently 0 job(s) in the queue.

<sub>This message was generated by `ablbot`. For help: use `@ablbot help`, see [`@ablbot`'s documentation](https://jupiter.office.ableton.com/docs/AbletonAppDev/ablbot), or the [Ableton CI Guide](https://jupiter.office.ableton.com/docs/AbletonCI).</sub>
author_association: NONE
user: login: ableton-ablbot[bot]
created_at: 2025-04-25T10:45:09Z
body: Rebase of `nre/master/2497-pyenv-windows` onto `main` has succeeded.

<sub>This message was generated by `ablbot`. For help: use `@ablbot help`, see [`@ablbot`'s documentation](https://jupiter.office.ableton.com/docs/AbletonAppDev/ablbot), or the [Ableton CI Guide](https://jupiter.office.ableton.com/docs/AbletonCI).</sub>
author_association: NONE