v0.48.1 deaab59

python_runtime.go RawHistoryFolder

1// Package tools Python runtime resolution.
2//
3// Resolution is lazy (per invocation, after permission checks) and shares
4// the caller's timeout budget. Priority:
5//
6//  1. Configured python_interpreter (one executable path, no PATH lookup).
7//  2. Active VIRTUAL_ENV.
8//  3. Session-root .venv.
9//  4. PATH candidates python3, then python.
10//
11// A configured or active environment that fails validation is an error,
12// never a silent fallthrough. A present but invalid .venv is reported.
13// A missing .venv permits system discovery. PATH candidates may fall
14// through; cancellation stops discovery immediately.
15package tools
16
17import (
18	"bytes"
19	"context"
20	"encoding/json"
21	"fmt"
22	"io"
23	"os"
24	"os/exec"
25	"path/filepath"
26	"runtime"
27	"strings"
28	"time"
29)
30
31// maxPythonProbeSeconds bounds each version probe. The probe also observes
32// the remaining invocation deadline, whichever is shorter.
33const maxPythonProbeSeconds = 5
34
35// maxPythonProbeOutput bounds captured probe stdout/stderr.
36const maxPythonProbeOutput = 64 * 1024
37
38// pythonProbeCode prints structured version info using only the standard
39// library. It runs under -I -S so user configuration cannot alter it.
40const pythonProbeCode = `import json,sys;print(json.dumps({"major":sys.version_info[0],"minor":sys.version_info[1],"micro":sys.version_info[2],"executable":sys.executable}))`
41
42// pythonProbeArgv builds the isolated probe argv for exe. -B suppresses
43// bytecode-cache writes during probing.
44func pythonProbeArgv(exe string) []string {
45	return []string{exe, "-I", "-S", "-B", "-c", pythonProbeCode}
46}
47
48// pythonVersion is a validated Python 3 interpreter identity.
49type pythonVersion struct {
50	Major      int
51	Minor      int
52	Micro      int
53	Executable string
54}
55
56func (v pythonVersion) String() string {
57	return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Micro)
58}
59
60// resolvedPython is a validated interpreter ready for execution.
61type resolvedPython struct {
62	Path    string
63	Version pythonVersion
64}
65
66// pythonResolveDeps are the injectable seams for resolution. Production
67// code fills them from the host; tests substitute fixtures. No
68// package-global mutable overrides.
69type pythonResolveDeps struct {
70	GOOS       string
71	CWD        string
72	Configured string
73	Getenv     func(string) string
74	LookPath   func(string) (string, error)
75	IsFile     func(string) bool
76	EvalSym    func(string) (string, error)
77	Probe      func(ctx context.Context, exe string) (pythonVersion, error)
78	// LayoutOK classifies a Windows candidate as a positively identified
79	// installed interpreter (or venv). Nil uses the default filesystem
80	// check. Ignored on non-Windows platforms.
81	LayoutOK func(exe string) bool
82}
83
84func (d *pythonResolveDeps) goos() string {
85	if d.GOOS != "" {
86		return d.GOOS
87	}
88	return runtime.GOOS
89}
90
91func (d *pythonResolveDeps) getenv(k string) string {
92	if d.Getenv != nil {
93		return d.Getenv(k)
94	}
95	return os.Getenv(k)
96}
97
98func (d *pythonResolveDeps) lookPath(name string) (string, error) {
99	if d.LookPath != nil {
100		return d.LookPath(name)
101	}
102	return exec.LookPath(name)
103}
104
105func (d *pythonResolveDeps) isFile(path string) bool {
106	if d.IsFile != nil {
107		return d.IsFile(path)
108	}
109	info, err := os.Stat(path)
110	return err == nil && !info.IsDir()
111}
112
113func (d *pythonResolveDeps) evalSym(path string) (string, error) {
114	if d.EvalSym != nil {
115		return d.EvalSym(path)
116	}
117	return filepath.EvalSymlinks(path)
118}
119
120func (d *pythonResolveDeps) probe(ctx context.Context, exe string) (pythonVersion, error) {
121	if d.Probe != nil {
122		return d.Probe(ctx, exe)
123	}
124	return probePythonVersion(ctx, exe)
125}
126
127func (d *pythonResolveDeps) layoutOK(exe string) bool {
128	if d.LayoutOK != nil {
129		return d.LayoutOK(exe)
130	}
131	return hasWindowsRuntimeLayout(exe, d.isFile)
132}
133
134// resolveConfiguredPythonPath maps a configured python_interpreter value to
135// a filesystem path. Absolute paths are used directly; relative paths
136// resolve against the session CWD, which is anchored to absolute first so
137// a relative --cwd cannot stack (project/project/...). No PATH lookup, no
138// shell expansion.
139func resolveConfiguredPythonPath(cwd, configured string) string {
140	if filepath.IsAbs(configured) {
141		return filepath.Clean(configured)
142	}
143	cwd = absPythonCWD(cwd)
144	return filepath.Join(cwd, filepath.FromSlash(configured))
145}
146
147// absPythonCWD anchors the session CWD to absolute once. Resolution and
148// execution both chdir into the session CWD, so every constructed
149// candidate must survive that chdir.
150func absPythonCWD(cwd string) string {
151	if cwd == "" {
152		cwd, _ = os.Getwd()
153	}
154	if abs, err := filepath.Abs(cwd); err == nil {
155		return abs
156	}
157	return cwd
158}
159
160// pythonVenvCandidate builds the interpreter path inside an environment
161// root. Relative roots resolve against the session CWD.
162func pythonVenvCandidate(cwd, root, goos string) string {
163	if !filepath.IsAbs(root) {
164		root = filepath.Join(absPythonCWD(cwd), filepath.FromSlash(root))
165	}
166	if goos == "windows" {
167		return filepath.Join(root, "Scripts", "python.exe")
168	}
169	return filepath.Join(root, "bin", "python")
170}
171
172// resolvePythonInterpreter implements the specified priority order.
173func resolvePythonInterpreter(ctx context.Context, deps pythonResolveDeps) (resolvedPython, error) {
174	goos := deps.goos()
175	cwd := absPythonCWD(deps.CWD)
176
177	if err := ctx.Err(); err != nil {
178		return resolvedPython{}, err
179	}
180
181	// 1. Configured interpreter: fail closed, never fall through.
182	if strings.TrimSpace(deps.Configured) != "" {
183		path := resolveConfiguredPythonPath(cwd, strings.TrimSpace(deps.Configured))
184		return resolvePythonFailClosed(ctx, &deps, goos, "configured python_interpreter", path)
185	}
186
187	// 2. Active VIRTUAL_ENV: fail closed, never fall through.
188	if venv := strings.TrimSpace(deps.getenv("VIRTUAL_ENV")); venv != "" {
189		path := pythonVenvCandidate(cwd, venv, goos)
190		return resolvePythonFailClosed(ctx, &deps, goos, "active VIRTUAL_ENV", path)
191	}
192
193	// 3. Session-root .venv: missing permits system discovery, but a
194	// present yet invalid environment is reported, not bypassed.
195	if venvCandidate := pythonVenvCandidate(cwd, filepath.Join(cwd, ".venv"), goos); deps.isFile(venvCandidate) {
196		return resolvePythonFailClosed(ctx, &deps, goos, "local .venv", venvCandidate)
197	} else {
198		// Also consider a .venv directory whose interpreter has a
199		// version-suffixed name? No: only the canonical layout is valid.
200		// A .venv dir without the canonical interpreter counts as
201		// present-but-invalid.
202		if dirExists(filepath.Join(cwd, ".venv")) {
203			return resolvedPython{}, fmt.Errorf("local .venv at %q has no usable Python 3 interpreter at %q; create the environment or configure python_interpreter to an existing interpreter (zut never installs Python or packages)", filepath.Join(cwd, ".venv"), venvCandidate)
204		}
205	}
206
207	// 4. PATH candidates: absent or non-Python-3 entries fall through.
208	for _, name := range []string{"python3", "python"} {
209		if err := ctx.Err(); err != nil {
210			return resolvedPython{}, err
211		}
212		path, err := deps.lookPath(name)
213		if err != nil || strings.TrimSpace(path) == "" {
214			continue
215		}
216		// A relative PATH entry can yield a relative interpreter path.
217		// The probe runs in the process directory while execution runs
218		// in the session CWD, so anchor it: probe, classification, and
219		// execution must all address the same validated executable.
220		if abs, absErr := filepath.Abs(path); absErr == nil {
221			path = abs
222		}
223		if goos == "windows" {
224			if err := classifyWindowsCandidate(path, &deps); err != nil {
225				continue
226			}
227		}
228		ver, err := deps.probe(ctx, path)
229		if err != nil {
230			if ctx.Err() != nil {
231				return resolvedPython{}, ctx.Err()
232			}
233			continue
234		}
235		return resolvedPython{Path: path, Version: ver}, nil
236	}
237
238	return resolvedPython{}, missingPythonError()
239}
240
241// resolvePythonFailClosed validates one explicit environment source. Any
242// failure is returned; the caller must not fall through to another source.
243func resolvePythonFailClosed(ctx context.Context, deps *pythonResolveDeps, goos, source, path string) (resolvedPython, error) {
244	if err := ctx.Err(); err != nil {
245		return resolvedPython{}, err
246	}
247	if !deps.isFile(path) {
248		return resolvedPython{}, fmt.Errorf("%s %q is not usable: executable not found; point python_interpreter at an existing Python 3 interpreter (zut never installs Python or packages)", source, path)
249	}
250	if goos == "windows" {
251		if err := classifyWindowsCandidate(path, deps); err != nil {
252			return resolvedPython{}, fmt.Errorf("%s %q is not usable: %w; configure python_interpreter to an existing installed Python 3 interpreter", source, path, err)
253		}
254	}
255	ver, err := deps.probe(ctx, path)
256	if err != nil {
257		if ctx.Err() != nil {
258			return resolvedPython{}, ctx.Err()
259		}
260		return resolvedPython{}, fmt.Errorf("%s %q is not usable: %v; configure python_interpreter to an existing Python 3 interpreter (zut never installs Python or packages)", source, path, err)
261	}
262	return resolvedPython{Path: path, Version: ver}, nil
263}
264
265func missingPythonError() error {
266	return fmt.Errorf("no Python 3 interpreter found: an existing Python 3 environment is required; configure python_interpreter in config.json, activate a VIRTUAL_ENV, create ./.venv, or add python3 to PATH (zut never installs Python or packages)")
267}
268
269func dirExists(path string) bool {
270	info, err := os.Stat(path)
271	return err == nil && info.IsDir()
272}
273
274// classifyWindowsCandidate enforces the Windows safety policy for EVERY
275// source, including configured paths and virtual environments. It runs
276// before any probe or execution. Unknown wrappers fail closed.
277func classifyWindowsCandidate(exe string, deps *pythonResolveDeps) error {
278	target := exe
279	if resolved, err := deps.evalSym(exe); err == nil && strings.TrimSpace(resolved) != "" {
280		target = resolved
281	}
282	lowerTarget := strings.ToLower(strings.ReplaceAll(target, "\\", "/"))
283	if strings.Contains(lowerTarget, "/windowsapps/") || strings.Contains(lowerTarget, "windowsapps") && strings.Contains(lowerTarget, "microsoft") {
284		return fmt.Errorf("windows Store alias is not supported")
285	}
286	// filepath.Base is separator-sensitive to the host OS, so extract the
287	// basename manually: test fixtures use Windows paths on Unix hosts.
288	base := lowerTarget
289	if i := strings.LastIndex(base, "/"); i >= 0 {
290		base = base[i+1:]
291	}
292	switch base {
293	case "py.exe", "pyw.exe", "pylauncher.exe":
294		return fmt.Errorf("python launcher %q is not supported; execute an installed interpreter directly", base)
295	case "pymanager.exe", "python-manager.exe", "python-install-manager.exe", "install-manager.exe", "installmanager.exe":
296		return fmt.Errorf("python install manager %q is not supported (named entrypoint)", base)
297	}
298	if strings.Contains(base, "manager") && (strings.Contains(base, "python") || strings.Contains(base, "py") || strings.Contains(base, "install")) {
299		return fmt.Errorf("python install manager %q is not supported (heuristic)", base)
300	}
301	if !deps.layoutOK(exe) && !deps.layoutOK(target) {
302		return fmt.Errorf("unverifiable interpreter wrapper %q is not supported", exe)
303	}
304	return nil
305}
306
307// hasWindowsRuntimeLayout positively identifies an installed CPython
308// executable or a virtual-environment interpreter. Anything else fails
309// closed. Never trust the basename alone.
310func hasWindowsRuntimeLayout(exe string, isFile func(string) bool) bool {
311	dir := filepath.Dir(exe)
312	parent := filepath.Dir(dir)
313	// Virtual environments carry pyvenv.cfg at the environment root.
314	if isFile(filepath.Join(dir, "pyvenv.cfg")) || isFile(filepath.Join(parent, "pyvenv.cfg")) {
315		return true
316	}
317	entries, err := os.ReadDir(dir)
318	if err == nil {
319		for _, e := range entries {
320			name := strings.ToLower(e.Name())
321			if strings.HasPrefix(name, "python") && strings.HasSuffix(name, ".dll") {
322				return true
323			}
324		}
325	}
326	// Installed layout also ships the standard library next to the exe.
327	if isFile(filepath.Join(dir, "Lib", "os.py")) || isFile(filepath.Join(parent, "Lib", "os.py")) {
328		return true
329	}
330	return false
331}
332
333// probePythonVersion runs the isolated version probe, bounded by at most
334// five seconds and the remaining invocation deadline.
335func probePythonVersion(ctx context.Context, exe string) (pythonVersion, error) {
336	timeout := maxPythonProbeSeconds * time.Second
337	if deadline, ok := ctx.Deadline(); ok {
338		remaining := time.Until(deadline)
339		if remaining <= 0 {
340			return pythonVersion{}, ctx.Err()
341		}
342		if remaining < timeout {
343			timeout = remaining
344		}
345	}
346	probeCtx, cancel := context.WithTimeout(ctx, timeout)
347	defer cancel()
348
349	cmd := exec.CommandContext(probeCtx, exe, "-I", "-S", "-B", "-c", pythonProbeCode)
350	cmd.Env = pythonChildEnv()
351	cmd.Dir = ""
352	var stdout, stderr bytes.Buffer
353	cmd.Stdout = &limitedWriter{W: &stdout, N: maxPythonProbeOutput}
354	cmd.Stderr = &limitedWriter{W: &stderr, N: maxPythonProbeOutput}
355	// Probes only surface terminal failures; a failed process-group kill
356	// is folded into the probe error by Wait, so no cancel capture here.
357	closeOutput := configurePythonProcess(cmd, nil, nil)
358	defer closeOutput()
359	if err := cmd.Start(); err != nil {
360		return pythonVersion{}, fmt.Errorf("probe start: %w", err)
361	}
362	waitErr := cmd.Wait()
363	if probeCtx.Err() != nil && ctx.Err() == nil {
364		return pythonVersion{}, fmt.Errorf("probe timed out after %d seconds", int(timeout/time.Second))
365	}
366	if waitErr != nil {
367		out := strings.TrimSpace(stdout.String() + stderr.String())
368		if out != "" {
369			if len(out) > 512 {
370				out = out[:512]
371			}
372			return pythonVersion{}, fmt.Errorf("probe failed: %v: %s", waitErr, out)
373		}
374		return pythonVersion{}, fmt.Errorf("probe failed: %w", waitErr)
375	}
376	var raw struct {
377		Major      int    `json:"major"`
378		Minor      int    `json:"minor"`
379		Micro      int    `json:"micro"`
380		Executable string `json:"executable"`
381	}
382	dec := json.NewDecoder(bytes.NewReader(stdout.Bytes()))
383	if err := dec.Decode(&raw); err != nil {
384		return pythonVersion{}, fmt.Errorf("probe returned invalid output")
385	}
386	if raw.Major != 3 {
387		return pythonVersion{}, fmt.Errorf("probe returned Python %d (need Python 3)", raw.Major)
388	}
389	return pythonVersion{Major: raw.Major, Minor: raw.Minor, Micro: raw.Micro, Executable: raw.Executable}, nil
390}
391
392// pythonChildEnv inherits the host environment consistently with Bash,
393// except for Windows launcher-manager opt-ins, which are stripped as
394// defense in depth. Setting them to "0" is insufficient, so they are
395// removed entirely. PYTHON_MANAGER_AUTOMATIC_INSTALL is never trusted.
396func pythonChildEnv() []string {
397	env := os.Environ()
398	if runtime.GOOS != "windows" {
399		return env
400	}
401	out := env[:0]
402	for _, kv := range env {
403		key := kv
404		if i := strings.IndexByte(kv, '='); i >= 0 {
405			key = kv[:i]
406		}
407		// Windows environment keys are case-insensitive: normalize before
408		// comparing so lowercase launcher opt-ins cannot survive.
409		switch strings.ToUpper(key) {
410		case "PYLAUNCHER_ALLOW_INSTALL", "PYLAUNCHER_ALWAYS_INSTALL", "PYTHON_MANAGER_AUTOMATIC_INSTALL":
411			continue
412		}
413		out = append(out, kv)
414	}
415	return out
416}
417
418type limitedWriter struct {
419	W *bytes.Buffer
420	N int
421}
422
423func (l *limitedWriter) Write(p []byte) (int, error) {
424	if l.W.Len() >= l.N {
425		return len(p), nil
426	}
427	room := l.N - l.W.Len()
428	if len(p) > room {
429		l.W.Write(p[:room])
430		return len(p), nil
431	}
432	l.W.Write(p)
433	return len(p), nil
434}
435
436var _ = io.Discard

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close