Repository: takluyver/flit (python)

Issue:
flit install --symlink doest not honor --python for scripts it installs
Hi,

I have been experimenting with flit recently and I very much like it's approach for development installs (`--symlink`).

I noticed what could be a small issues with such installations.

I have flit installed globally (with pipx).

When I try to install my project with `pip install --symlink --python $(which python)` in a virtualenv, I notice the installed script uses flit's python as shebang instead of the python of the virtualenv.

I believe the problem arises in this `install_script`, called via `install_directly`:
https://github.com/takluyver/flit/blob/f5996dcee05656ca7a1e6ed2e5c82a6e911f4f10/flit/install.py#L171


Pull request:
fix shebang in scripts installed with --symlink 
Fixes #285 

I also fixed a deprecation warning for an invalid escape sequence.

Patch:
diff --git a/flit/__init__.py b/flit/__init__.py
index 64e63e3..3c039ca 100644
--- a/flit/__init__.py
+++ b/flit/__init__.py
@@ -1,7 +1,9 @@
 """A simple packaging tool for simple packages."""
 import argparse
 import logging
+import os
 import pathlib
+import subprocess
 import sys
 
 from . import common
@@ -11,6 +13,18 @@ __version__ = '1.3'
 
 log = logging.getLogger(__name__)
 
+
+def find_python_executable(python):
+    if not python:
+        return sys.executable
+    if os.path.isabs(python):  # sys.executable is absolute too
+        return python
+    return subprocess.check_output(
+        [python, "-c", "import sys; print(sys.executable)"],
+        universal_newlines=True,
+    ).strip()
+
+
 def add_shared_install_options(parser):
     parser.add_argument('--user', action='store_true', default=None,
         help="Do a user-local install (default if site.ENABLE_USER_SITE is True)"
@@ -18,7 +32,7 @@ def add_shared_install_options(parser):
     parser.add_argument('--env', action='store_false', dest='user',
         help="Install into sys.prefix (default if site.ENABLE_USER_SITE is False, i.e. in virtualenvs)"
     )
-    parser.add_argument('--python', default=sys.executable,
+    parser.add_argument('--python',
         help="Target Python executable, if different from the one running flit"
     )
 
@@ -117,14 +131,16 @@ def main(argv=None):
     elif args.subcmd == 'install':
         from .install import Installer
         try:
-            Installer(args.ini_file, user=args.user, python=args.python,
+            python = find_python_executable(args.python)
+            Installer(args.ini_file, user=args.user, python=python,
                       symlink=args.symlink, deps=args.deps, extras=args.extras,
                       pth=args.pth_file).install()
         except (common.NoDocstringError, common.NoVersionError) as e:
             sys.exit(e.args[0])
     elif args.subcmd == 'installfrom':
         from .installfrom import installfrom
-        sys.exit(installfrom(args.location, user=args.user, python=args.python))
+        python = find_python_executable(args.python)
+        sys.exit(installfrom(args.location, user=args.user, python=python))
     elif args.subcmd == 'init':
         from .init import TerminalIniter
         TerminalIniter().initialise()
diff --git a/flit/install.py b/flit/install.py
index 9e66aff..d5abbae 100644
--- a/flit/install.py
+++ b/flit/install.py
@@ -168,7 +168,7 @@ class Installer(object):
             log.info('Writing script to %s', script_file)
             with script_file.open('w', encoding='utf-8') as f:
                 f.write(common.script_template.format(
-                    interpreter=sys.executable,
+                    interpreter=self.python,
                     module=module,
                     func=func
                 ))
@@ -178,8 +178,8 @@ class Installer(object):
 
             if sys.platform == 'win32':
                 cmd_file = script_file.with_suffix('.cmd')
-                cmd = '"{python}" "%~dp0\{script}" %*\r\n'.format(
-                            python=sys.executable, script=name)
+                cmd = '"{python}" "%~dp0\\{script}" %*\r\n'.format(
+                            python=self.python, script=name)
                 log.debug("Writing script wrapper to %s", cmd_file)
                 with cmd_file.open('w') as f:
                     f.write(cmd)


Test patch:
diff --git a/tests/test_find_python_executable.py b/tests/test_find_python_executable.py
new file mode 100644
index 0000000..685dd61
--- /dev/null
+++ b/tests/test_find_python_executable.py
@@ -0,0 +1,20 @@
+import os
+import sys
+
+from flit import find_python_executable
+
+
+def test_default():
+    assert find_python_executable(None) == sys.executable
+
+
+def test_self():
+    assert find_python_executable(sys.executable) == sys.executable
+
+
+def test_abs():
+    assert find_python_executable("/usr/bin/python") == "/usr/bin/python"
+
+
+def test_find_in_path():
+    assert os.path.isabs(find_python_executable("python"))
diff --git a/tests/test_install.py b/tests/test_install.py
index 9c248d0..68be7d8 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -37,6 +37,8 @@ class InstallTests(TestCase):
         assert_isdir(self.tmpdir / 'site-packages' / 'package1')
         assert_isdir(self.tmpdir / 'site-packages' / 'package1-0.1.dist-info')
         assert_isfile(self.tmpdir / 'scripts' / 'pkg_script')
+        with (self.tmpdir / 'scripts' / 'pkg_script').open() as f:
+            assert f.readline().strip() == "#!" + sys.executable
 
     def test_symlink_package(self):
         if os.name == 'nt':
@@ -45,6 +47,8 @@ class InstallTests(TestCase):
         assert_islink(self.tmpdir / 'site-packages' / 'package1',
                       to=str(samples_dir / 'package1'))
         assert_isfile(self.tmpdir / 'scripts' / 'pkg_script')
+        with (self.tmpdir / 'scripts' / 'pkg_script').open() as f:
+            assert f.readline().strip() == "#!" + sys.executable
 
     def test_pth_package(self):
         Installer(samples_dir / 'package1-pkg.ini', pth=True).install()
@@ -107,6 +111,8 @@ class InstallTests(TestCase):
         assert_islink(self.tmpdir / 'site-packages2' / 'package1',
                       to=str(samples_dir / 'package1'))
         assert_isfile(self.tmpdir / 'scripts2' / 'pkg_script')
+        with (self.tmpdir / 'scripts2' / 'pkg_script').open() as f:
+            assert f.readline().strip() == "#!mock_python"
 
     def test_install_requires(self):
         ins = Installer(samples_dir / 'requires-requests.toml',