1from __future__ import absolute_import, unicode_literals
2
3import os
4import pipes
5import re
6import subprocess
7import sys
8from os.path import dirname, join, normcase, realpath
9
10import pytest
11import six
12
13import virtualenv
14
15IS_INSIDE_CI = "CI_RUN" in os.environ
16
17
18def need_executable(name, check_cmd):
19    """skip running this locally if executable not found, unless we're inside the CI"""
20
21    def wrapper(fn):
22        fn = getattr(pytest.mark, name)(fn)
23        if not IS_INSIDE_CI:
24            # locally we disable, so that contributors don't need to have everything setup
25            # noinspection PyBroadException
26            try:
27                fn.version = subprocess.check_output(check_cmd, env=get_env())
28            except Exception as exception:
29                return pytest.mark.skip(reason="{} is not available due {}".format(name, exception))(fn)
30        return fn
31
32    return wrapper
33
34
35def requires(on):
36    def wrapper(fn):
37        return need_executable(on.cmd.replace(".exe", ""), on.check)(fn)
38
39    return wrapper
40
41
42def norm_path(path):
43    # python may return Windows short paths, normalize
44    path = realpath(path)
45    if virtualenv.IS_WIN:
46        from ctypes import create_unicode_buffer, windll
47
48        buffer_cont = create_unicode_buffer(256)
49        get_long_path_name = windll.kernel32.GetLongPathNameW
50        get_long_path_name(six.text_type(path), buffer_cont, 256)  # noqa: F821
51        result = buffer_cont.value
52    else:
53        result = path
54    return normcase(result)
55
56
57class Activation(object):
58    cmd = ""
59    extension = "test"
60    invoke_script = []
61    command_separator = os.linesep
62    activate_cmd = "source"
63    activate_script = ""
64    check_has_exe = []
65    check = []
66    env = {}
67    also_test_error_if_not_sourced = False
68
69    def __init__(self, activation_env, tmp_path):
70        self.home_dir = activation_env[0]
71        self.bin_dir = activation_env[1]
72        self.path = tmp_path
73
74    def quote(self, s):
75        return pipes.quote(s)
76
77    def python_cmd(self, cmd):
78        return "{} -c {}".format(self.quote(virtualenv.EXPECTED_EXE), self.quote(cmd))
79
80    def python_script(self, script):
81        return "{} {}".format(self.quote(virtualenv.EXPECTED_EXE), self.quote(script))
82
83    def print_python_exe(self):
84        return self.python_cmd("import sys; print(sys.executable)")
85
86    def print_os_env_var(self, var):
87        val = '"{}"'.format(var)
88        return self.python_cmd("import os; print(os.environ.get({}, None))".format(val))
89
90    def __call__(self, monkeypatch):
91        absolute_activate_script = norm_path(join(self.bin_dir, self.activate_script))
92
93        commands = [
94            self.print_python_exe(),
95            self.print_os_env_var("VIRTUAL_ENV"),
96            self.activate_call(absolute_activate_script),
97            self.print_python_exe(),
98            self.print_os_env_var("VIRTUAL_ENV"),
99            # pydoc loads documentation from the virtualenv site packages
100            "pydoc -w pydoc_test",
101            "deactivate",
102            self.print_python_exe(),
103            self.print_os_env_var("VIRTUAL_ENV"),
104            "",  # just finish with an empty new line
105        ]
106        script = self.command_separator.join(commands)
107        test_script = self.path / "script.{}".format(self.extension)
108        test_script.write_text(script)
109        assert test_script.exists()
110
111        monkeypatch.chdir(str(self.path))
112        invoke_shell = self.invoke_script + [str(test_script)]
113
114        monkeypatch.delenv(str("VIRTUAL_ENV"), raising=False)
115
116        # in case the tool is provided by the dev environment (e.g. xonosh)
117        env = get_env()
118        env.update(self.env)
119
120        try:
121            raw = subprocess.check_output(invoke_shell, universal_newlines=True, stderr=subprocess.STDOUT, env=env)
122        except subprocess.CalledProcessError as exception:
123            assert not exception.returncode, exception.output
124        out = re.sub(r"pydev debugger: process \d+ is connecting\n\n", "", raw, re.M).strip().split("\n")
125
126        # pre-activation
127        assert out[0], raw
128        assert out[1] == "None", raw
129
130        # post-activation
131        exe = "{}.exe".format(virtualenv.EXPECTED_EXE) if virtualenv.IS_WIN else virtualenv.EXPECTED_EXE
132        assert norm_path(out[2]) == norm_path(join(self.bin_dir, exe)), raw
133        assert norm_path(out[3]) == norm_path(str(self.home_dir)).replace("\\\\", "\\"), raw
134
135        assert out[4] == "wrote pydoc_test.html"
136        content = self.path / "pydoc_test.html"
137        assert content.exists(), raw
138
139        # post deactivation, same as before
140        assert out[-2] == out[0], raw
141        assert out[-1] == "None", raw
142
143        if self.also_test_error_if_not_sourced:
144            invoke_shell = self.invoke_script + [absolute_activate_script]
145
146            with pytest.raises(subprocess.CalledProcessError) as c:
147                subprocess.check_output(invoke_shell, stderr=subprocess.STDOUT, env=env)
148            assert c.value.returncode, c
149
150    def activate_call(self, script):
151        return "{} {}".format(pipes.quote(self.activate_cmd), pipes.quote(script)).strip()
152
153
154def get_env():
155    env = os.environ.copy()
156    env[str("PATH")] = os.pathsep.join([dirname(sys.executable)] + env.get(str("PATH"), str("")).split(os.pathsep))
157    return env
158
159
160class BashActivation(Activation):
161    cmd = "bash.exe" if virtualenv.IS_WIN else "bash"
162    invoke_script = [cmd]
163    extension = "sh"
164    activate_script = "activate"
165    check = [cmd, "--version"]
166    also_test_error_if_not_sourced = True
167
168
169@pytest.mark.skipif(sys.platform == "win32", reason="no sane way to provision bash on Windows yet")
170@requires(BashActivation)
171def test_bash(clean_python, monkeypatch, tmp_path):
172    BashActivation(clean_python, tmp_path)(monkeypatch)
173
174
175class CshActivation(Activation):
176    cmd = "csh.exe" if virtualenv.IS_WIN else "csh"
177    invoke_script = [cmd]
178    extension = "csh"
179    activate_script = "activate.csh"
180    check = [cmd, "--version"]
181
182
183@pytest.mark.skipif(sys.platform == "win32", reason="no sane way to provision csh on Windows yet")
184@requires(CshActivation)
185def test_csh(clean_python, monkeypatch, tmp_path):
186    CshActivation(clean_python, tmp_path)(monkeypatch)
187
188
189class FishActivation(Activation):
190    cmd = "fish.exe" if virtualenv.IS_WIN else "fish"
191    invoke_script = [cmd]
192    extension = "fish"
193    activate_script = "activate.fish"
194    check = [cmd, "--version"]
195
196
197@pytest.mark.skipif(sys.platform == "win32", reason="no sane way to provision fish on Windows yet")
198@requires(FishActivation)
199def test_fish(clean_python, monkeypatch, tmp_path):
200    FishActivation(clean_python, tmp_path)(monkeypatch)
201
202
203class PowershellActivation(Activation):
204    cmd = "powershell.exe" if virtualenv.IS_WIN else "pwsh"
205    extension = "ps1"
206    invoke_script = [cmd, "-File"]
207    activate_script = "activate.ps1"
208    activate_cmd = "."
209    check = [cmd, "-c", "$PSVersionTable"]
210
211    def quote(self, s):
212        """powershell double double quote needed for quotes within single quotes"""
213        return pipes.quote(s).replace('"', '""')
214
215
216@requires(PowershellActivation)
217def test_powershell(clean_python, monkeypatch, tmp_path):
218    PowershellActivation(clean_python, tmp_path)(monkeypatch)
219
220
221class XonoshActivation(Activation):
222    cmd = "xonsh"
223    extension = "xsh"
224    invoke_script = [sys.executable, "-m", "xonsh"]
225    activate_script = "activate.xsh"
226    check = [sys.executable, "-m", "xonsh", "--version"]
227    env = {"XONSH_DEBUG": "1", "XONSH_SHOW_TRACEBACK": "True"}
228
229    def activate_call(self, script):
230        return "{} {}".format(self.activate_cmd, repr(script)).strip()
231
232
233@pytest.mark.skipif(sys.version_info < (3, 5), reason="xonosh requires Python 3.5 at least")
234@requires(XonoshActivation)
235def test_xonosh(clean_python, monkeypatch, tmp_path):
236    XonoshActivation(clean_python, tmp_path)(monkeypatch)
237