|
6 | 6 |
|
7 | 7 | import os |
8 | 8 | from dataclasses import dataclass |
9 | | -from pathlib import Path |
| 9 | +from functools import cache |
| 10 | +from pathlib import Path, PureWindowsPath |
10 | 11 | from shutil import which |
| 12 | +from subprocess import run |
11 | 13 | from typing import NamedTuple |
12 | 14 |
|
13 | 15 | import keyring |
|
24 | 26 | # instead of silently skipping every test that touches the keyring |
25 | 27 | REQUIRE_OS_KEYRING = os.environ.get("KEYCMD_REQUIRE_OS_KEYRING", "") not in {"", "0"} |
26 | 28 |
|
| 29 | +# and this on the job that installs WSL, for the same reason: the tests |
| 30 | +# that cross the interop boundary run wherever a distribution answers, so |
| 31 | +# only the run that provisioned one can tell a skip from a broken setup |
| 32 | +REQUIRE_WSL = os.environ.get("KEYCMD_REQUIRE_WSL", "") not in {"", "0"} |
| 33 | + |
27 | 34 | # shells to exercise, if installed |
28 | 35 | POSIX_SHELLS = ("sh", "bash", "zsh") |
29 | 36 | WINDOWS_SHELLS = ("cmd", "powershell") |
|
46 | 53 |
|
47 | 54 |
|
48 | 55 | def pytest_report_header(config): |
49 | | - """Report what this run picked up, both of which vary per machine |
| 56 | + """Report what this run picked up, all of which varies per machine |
50 | 57 |
|
51 | | - Which shells a run covers is otherwise only visible in the ids of the |
52 | | - tests that failed, so a run where they all pass does not say whether a |
53 | | - shell was exercised or simply absent. |
| 58 | + Which shells a run covers, and whether it reached a WSL distribution, |
| 59 | + is otherwise only visible in the ids of the tests that failed, so a |
| 60 | + run where they all pass does not say whether a shell was exercised or |
| 61 | + simply absent. |
54 | 62 | """ |
55 | 63 | shells = ", ".join(shell.name for shell in installed_shells()) |
| 64 | + found = find_wsl() |
| 65 | + wsl = found.distro if isinstance(found, Wsl) else f"none, {found}" |
56 | 66 | return [ |
57 | 67 | f"keyring backend: {keyring.get_keyring()}", |
58 | 68 | f"shells exercised: {shells or 'none'}", |
| 69 | + f"WSL distribution: {wsl}", |
59 | 70 | ] |
60 | 71 |
|
61 | 72 |
|
@@ -141,6 +152,124 @@ def detect_shell(pid): |
141 | 152 | return fake_shell |
142 | 153 |
|
143 | 154 |
|
| 155 | +def decode(raw): |
| 156 | + """Text out of a distribution, whichever side of the boundary wrote it |
| 157 | +
|
| 158 | + The linux side writes utf-8, while wsl.exe reports its own errors in |
| 159 | + utf-16; a NUL byte gives those away, since utf-8 output has none. |
| 160 | + Undecodable bytes are replaced rather than raised on, so that output |
| 161 | + that went wrong still reaches the assertion it explains. |
| 162 | + """ |
| 163 | + if b"\x00" in raw: |
| 164 | + return raw.decode("utf-16", errors="replace") |
| 165 | + return raw.decode("utf-8", errors="replace") |
| 166 | + |
| 167 | + |
| 168 | +class Output(NamedTuple): |
| 169 | + """What a command run inside a distribution had to say""" |
| 170 | + |
| 171 | + status: int |
| 172 | + stdout: str |
| 173 | + stderr: str |
| 174 | + |
| 175 | + @property |
| 176 | + def output(self): |
| 177 | + """Both streams, for the message of the assertion that failed""" |
| 178 | + return f"{self.stdout}\n{self.stderr}" |
| 179 | + |
| 180 | + |
| 181 | +def wsl_run(*args): |
| 182 | + """Run a command in the default WSL distribution""" |
| 183 | + p = run(["wsl.exe", "--", *args], capture_output=True) |
| 184 | + return Output(p.returncode, decode(p.stdout), decode(p.stderr)) |
| 185 | + |
| 186 | + |
| 187 | +def wsl_sh(script): |
| 188 | + """Run a shell script in the default WSL distribution |
| 189 | +
|
| 190 | + Plain sh, since not every distribution ships bash. |
| 191 | + """ |
| 192 | + return wsl_run("sh", "-eu", "-c", script) |
| 193 | + |
| 194 | + |
| 195 | +def wsl_path(path): |
| 196 | + """Translate a Windows path into the path WSL knows it by |
| 197 | +
|
| 198 | + Done here rather than with wslpath, which is not part of every |
| 199 | + distribution's root file system, and whose backslashes would not |
| 200 | + survive the trip through wsl.exe's command line anyway. |
| 201 | + """ |
| 202 | + path = PureWindowsPath(path) |
| 203 | + drive = path.drive |
| 204 | + assert drive.endswith(":"), f"not an absolute windows path: {path}" |
| 205 | + rest = path.as_posix()[len(drive) :].lstrip("/") |
| 206 | + translated = f"/mnt/{drive[0].lower()}/{rest}" |
| 207 | + # quotes are stripped from wsl.exe's command line before the distribution |
| 208 | + # ever sees them, so a path with spaces cannot be passed through it |
| 209 | + assert " " not in translated, f"path with spaces: {translated}" |
| 210 | + return translated |
| 211 | + |
| 212 | + |
| 213 | +@dataclass(frozen=True) |
| 214 | +class Wsl: |
| 215 | + """A distribution to run commands in, and the keycmd it can reach""" |
| 216 | + |
| 217 | + distro: str |
| 218 | + # the windows console script, as WSL users reach it through the PATH |
| 219 | + keycmd: str |
| 220 | + |
| 221 | + def sh(self, script): |
| 222 | + """Run a shell script inside the distribution""" |
| 223 | + return wsl_sh(script) |
| 224 | + |
| 225 | + def path(self, path): |
| 226 | + """The path this distribution knows a windows path by""" |
| 227 | + return wsl_path(path) |
| 228 | + |
| 229 | + |
| 230 | +@cache |
| 231 | +def find_wsl(): |
| 232 | + """A WSL boundary this run can test across, or the reason there is none |
| 233 | +
|
| 234 | + Windows keycmd called from a distribution is the half of the WSL setup |
| 235 | + that needs both sides of the boundary to be real, so it is tested |
| 236 | + wherever both are there and skipped, with the reason, where they are |
| 237 | + not. The answer is worked out once and reported in the header, since a |
| 238 | + run that skipped these silently looks exactly like a run without WSL. |
| 239 | + """ |
| 240 | + if not IS_WINDOWS: |
| 241 | + return "keycmd only crosses the WSL boundary as a windows process" |
| 242 | + if which("wsl.exe") is None: |
| 243 | + return "wsl.exe is not installed" |
| 244 | + path = which("keycmd") |
| 245 | + if path is None: |
| 246 | + return "the keycmd console script is not on PATH" |
| 247 | + if " " in path: |
| 248 | + # wsl.exe strips the quotes that would hold it together, so a path |
| 249 | + # with spaces cannot be handed to the distribution at all |
| 250 | + return f"the keycmd console script is under a path with spaces: {path}" |
| 251 | + # a distribution that answers, rather than one that is merely |
| 252 | + # registered: wsl.exe is on PATH on windows whether or not there is |
| 253 | + # anything behind it, and docker's distributions run no shell |
| 254 | + found = wsl_sh("echo ${WSL_DISTRO_NAME:-default}") |
| 255 | + if found.status != 0 or not found.stdout.strip(): |
| 256 | + said = " ".join(found.output.split()) |
| 257 | + return f"no WSL distribution answered: {said or f'exit status {found.status}'}" |
| 258 | + return Wsl(distro=found.stdout.strip(), keycmd=wsl_path(path)) |
| 259 | + |
| 260 | + |
| 261 | +@pytest.fixture(scope="session") |
| 262 | +def wsl(): |
| 263 | + """A WSL distribution with the windows keycmd reachable from it""" |
| 264 | + found = find_wsl() |
| 265 | + if isinstance(found, str): |
| 266 | + msg = f"no WSL to test against: {found}" |
| 267 | + if REQUIRE_WSL: |
| 268 | + pytest.fail(msg) |
| 269 | + pytest.skip(msg) |
| 270 | + return found |
| 271 | + |
| 272 | + |
144 | 273 | @pytest.fixture(autouse=True) |
145 | 274 | def outside_wsl(monkeypatch): |
146 | 275 | """Keep the suite off the WSL code path unless a test asks for it |
|
0 commit comments