From a379b2fb1d51e8df4ff6983b669a5c8982346ece Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 9 Aug 2026 06:47:55 -0400 Subject: [PATCH 1/2] fix(python): make read-only test snapshot robust on real checkouts (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default Python test adapter snapshots the source into an ephemeral tree so packaging backends never write *.egg-info (or any build artifact) into the user's checkout. It used os.CopyFS, which aborts the entire copy on the first irregular file (a stray unix socket / FIFO) and duplicates regenerable VCS/venv/cache trees on every run — dragging stale lock and venv state into a run uv is meant to materialize fresh. Replace it with a walk-based snapshot that skips irregular files instead of failing, and excludes .git/.venv/caches/prior *.egg-info so the source package and its declared dependencies still resolve through the real uv adapter while the checkout stays untouched. Co-Authored-By: Claude Opus 4.8 --- runners/python/pytest.go | 85 ++++++++++++++++++++++++++++++++- runners/python/pytest_test.go | 88 +++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/runners/python/pytest.go b/runners/python/pytest.go index 8a99a770..b5bdfbc6 100644 --- a/runners/python/pytest.go +++ b/runners/python/pytest.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -196,7 +197,7 @@ func RunPythonTestsStructured(ctx context.Context, sourceDir string, envVars []* // Codefly runtime (the hands), not Mind; it is removed with the evidence // directory after the run. runtimeSourceDir := filepath.Join(junitDir, "source") - if err := os.CopyFS(runtimeSourceDir, os.DirFS(sourceDir)); err != nil { + if err := snapshotSourceTree(sourceDir, runtimeSourceDir); err != nil { return nil, fmt.Errorf("snapshot Python source for read-only test execution: %w", err) } @@ -359,6 +360,88 @@ func RunPythonTestsStructured(ctx context.Context, sourceDir string, envVars []* return run, runErr } +// snapshotSkipDirs names directories that must never enter the read-only test +// snapshot: version control, virtualenvs, tool caches, and foreign-ecosystem +// trees. They are regenerable and often huge, and — worse — copying a stale +// .venv or lockfile would let old resolution state leak into a run the issue +// requires uv to materialize fresh. Prior *.egg-info directories are skipped by +// suffix for the same reason. +var snapshotSkipDirs = map[string]bool{ + ".git": true, + ".hg": true, + ".svn": true, + ".venv": true, + "venv": true, + ".tox": true, + ".nox": true, + "__pycache__": true, + ".pytest_cache": true, + ".mypy_cache": true, + ".ruff_cache": true, + "node_modules": true, +} + +// snapshotSourceTree copies the Python source at src into dst for a read-only +// test run. It exists instead of os.CopyFS for two reasons the default adapter +// must survive on real checkouts: it skips the regenerable VCS/venv/cache and +// prior build-metadata directories above (so stale state cannot leak in and a +// full checkout is not duplicated per run), and it skips irregular files +// (sockets, FIFOs, devices) instead of aborting the whole copy — a single stray +// socket in a checkout must not break test validation. Regular files preserve +// their mode; symlinks are recreated verbatim. +func snapshotSourceTree(src, dst string) error { + return filepath.WalkDir(src, func(p string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, p) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if entry.IsDir() { + name := entry.Name() + if rel != "." && (snapshotSkipDirs[name] || strings.HasSuffix(name, ".egg-info")) { + return filepath.SkipDir + } + return os.MkdirAll(target, 0o755) + } + if entry.Type()&os.ModeSymlink != 0 { + linkDest, err := os.Readlink(p) + if err != nil { + return err + } + return os.Symlink(linkDest, target) + } + if !entry.Type().IsRegular() { + return nil + } + return copyRegularFile(p, target, entry) + }) +} + +// copyRegularFile copies a single regular file, preserving its permission bits. +func copyRegularFile(src, dst string, entry fs.DirEntry) error { + info, err := entry.Info() + if err != nil { + return err + } + in, err := os.Open(src) //nolint:gosec // snapshotting a plugin-owned source tree + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm()) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + return out.Close() +} + // scrapeCoverageFromOutput extracts the total coverage percentage from // pytest-cov's terminal output. Looks for the "TOTAL ... NN%" line // pytest-cov emits with --cov-report=term. diff --git a/runners/python/pytest_test.go b/runners/python/pytest_test.go index 5ff831b9..05eae4ba 100644 --- a/runners/python/pytest_test.go +++ b/runners/python/pytest_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "sync" + "syscall" "testing" "time" ) @@ -133,6 +134,93 @@ func assertDefaultRunnerLeftSourceClean(t *testing.T, root string) { } } +// TestRunPythonTestsStructuredToleratesIrregularFilesInCheckout proves the +// read-only snapshot survives a checkout that contains an irregular file (a +// stray unix socket / FIFO — common under a live-dev tree). os.CopyFS aborts +// the whole copy on such a file; the default adapter must run the tests anyway +// and still leave the source clean. +func TestRunPythonTestsStructuredToleratesIrregularFilesInCheckout(t *testing.T) { + if _, err := exec.LookPath("uv"); err != nil { + t.Fatalf("uv is required for the production Python runner: %v", err) + } + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "test_ok.py"), + []byte("def test_ok():\n assert True\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := syscall.Mkfifo(filepath.Join(root, "runtime.sock"), 0o644); err != nil { + t.Fatalf("mkfifo: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + run, err := RunPythonTestsStructured(ctx, root, nil, TestOptions{VerboseSet: true}) + if err != nil { + t.Fatalf("RunPythonTestsStructured: %v\n%s", err, run.RawOutput) + } + if run.EnvError != nil { + t.Fatalf("default adapter environment error: %s\n%s", run.EnvError.Detail, run.RawOutput) + } + if summary := run.LegacyTestSummary(); summary.Run != 1 || summary.Passed != 1 { + t.Fatalf("summary = %+v, want one passed test\n%s", summary, run.RawOutput) + } + assertDefaultRunnerLeftSourceClean(t, root) +} + +// TestSnapshotSourceTree exercises the snapshot copy directly: source files are +// reproduced, symlinks are preserved, but regenerable/VCS directories, prior +// build metadata, and irregular files never enter the snapshot. +func TestSnapshotSourceTree(t *testing.T) { + src := t.TempDir() + write := func(p, c string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(filepath.Join(src, p)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, p), []byte(c), 0o644); err != nil { + t.Fatal(err) + } + } + write("pkg/module.py", "VALUE = 1\n") + write("requirements.txt", "./dep\n") + write(".git/config", "[core]\n") + write(".venv/bin/python", "#!/bin/sh\n") + write("pkg.egg-info/PKG-INFO", "Metadata-Version: 2.1\n") + write("__pycache__/module.cpython-312.pyc", "bytecode\n") + if err := os.Symlink("module.py", filepath.Join(src, "pkg", "link.py")); err != nil { + t.Fatal(err) + } + if err := syscall.Mkfifo(filepath.Join(src, "runtime.sock"), 0o644); err != nil { + t.Fatalf("mkfifo: %v", err) + } + + dst := filepath.Join(t.TempDir(), "snapshot") + if err := snapshotSourceTree(src, dst); err != nil { + t.Fatalf("snapshotSourceTree: %v", err) + } + + present := func(p string) bool { + _, err := os.Lstat(filepath.Join(dst, p)) + return err == nil + } + for _, p := range []string{"pkg/module.py", "requirements.txt"} { + if !present(p) { + t.Errorf("snapshot missing source file %s", p) + } + } + info, err := os.Lstat(filepath.Join(dst, "pkg", "link.py")) + if err != nil { + t.Errorf("snapshot missing symlink pkg/link.py: %v", err) + } else if info.Mode()&os.ModeSymlink == 0 { + t.Errorf("snapshot did not preserve pkg/link.py as a symlink") + } + for _, p := range []string{".git", ".venv", "pkg.egg-info", "__pycache__", "runtime.sock"} { + if present(p) { + t.Errorf("snapshot copied excluded entry %s", p) + } + } +} + // TestScanPytestEvents_EmitsPerLine feeds realistic pytest verbose // output through scanPytestEvents and asserts the callback fires once // per progress line, in order. From 7483def661b942edae62c1fce442931e74d2cb36 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 9 Aug 2026 10:09:18 -0400 Subject: [PATCH 2/2] fix(python): preserve .git and follow symlinked root in test snapshot (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the snapshot copy surfaced two regressions vs the os.CopyFS it replaced, plus a stale comment: - A symlinked source root produced a dangling symlink instead of a copy, so the whole run executed in a non-existent directory. os.DirFS/os.CopyFS follow the root; filepath.WalkDir does not. Resolve the root with EvalSymlinks before walking (symlinks inside the tree stay verbatim). - Excluding .git broke build backends that derive a dynamic version from git (setuptools_scm), which env-errored a run that should pass. VCS dirs are build input, not stale state — drop them from the skip set. - The skip-set comment claimed lockfile exclusion that never happened; reword it to match what is actually skipped. Co-Authored-By: Claude Opus 4.8 --- runners/python/pytest.go | 41 +++++++----- runners/python/pytest_test.go | 120 ++++++++++++++++++++++++++++++++-- 2 files changed, 138 insertions(+), 23 deletions(-) diff --git a/runners/python/pytest.go b/runners/python/pytest.go index b5bdfbc6..2cd3199f 100644 --- a/runners/python/pytest.go +++ b/runners/python/pytest.go @@ -360,16 +360,15 @@ func RunPythonTestsStructured(ctx context.Context, sourceDir string, envVars []* return run, runErr } -// snapshotSkipDirs names directories that must never enter the read-only test -// snapshot: version control, virtualenvs, tool caches, and foreign-ecosystem -// trees. They are regenerable and often huge, and — worse — copying a stale -// .venv or lockfile would let old resolution state leak into a run the issue -// requires uv to materialize fresh. Prior *.egg-info directories are skipped by -// suffix for the same reason. +// snapshotSkipDirs names directories that never belong in the read-only test +// snapshot: virtualenvs, tool caches, and foreign-ecosystem trees. They are +// regenerable and often large, and the fresh uv run rebuilds its own +// environment rather than reading them. Version-control directories are +// deliberately NOT listed: build backends that derive a dynamic version +// (setuptools_scm and friends) read .git during the build, so dropping it would +// fail the very run we are isolating. Prior *.egg-info directories ARE skipped +// (by suffix) so stale build metadata cannot shadow what the run regenerates. var snapshotSkipDirs = map[string]bool{ - ".git": true, - ".hg": true, - ".svn": true, ".venv": true, "venv": true, ".tox": true, @@ -382,19 +381,25 @@ var snapshotSkipDirs = map[string]bool{ } // snapshotSourceTree copies the Python source at src into dst for a read-only -// test run. It exists instead of os.CopyFS for two reasons the default adapter -// must survive on real checkouts: it skips the regenerable VCS/venv/cache and -// prior build-metadata directories above (so stale state cannot leak in and a -// full checkout is not duplicated per run), and it skips irregular files -// (sockets, FIFOs, devices) instead of aborting the whole copy — a single stray -// socket in a checkout must not break test validation. Regular files preserve -// their mode; symlinks are recreated verbatim. +// test run. It exists instead of os.CopyFS for three reasons the default +// adapter must survive on real checkouts: it resolves a symlinked source root +// (WalkDir alone would yield the root as a lone symlink entry and copy nothing, +// where os.DirFS/os.CopyFS follow it); it skips the regenerable venv/cache and +// prior build-metadata directories in snapshotSkipDirs so stale state cannot +// leak in and a full checkout is not duplicated per run; and it skips irregular +// files (sockets, FIFOs, devices) instead of aborting the whole copy — a single +// stray socket in a checkout must not break test validation. Regular files +// preserve their mode; symlinks inside the tree are recreated verbatim. func snapshotSourceTree(src, dst string) error { - return filepath.WalkDir(src, func(p string, entry fs.DirEntry, err error) error { + root, err := filepath.EvalSymlinks(src) + if err != nil { + return err + } + return filepath.WalkDir(root, func(p string, entry fs.DirEntry, err error) error { if err != nil { return err } - rel, err := filepath.Rel(src, p) + rel, err := filepath.Rel(root, p) if err != nil { return err } diff --git a/runners/python/pytest_test.go b/runners/python/pytest_test.go index 05eae4ba..7212c17b 100644 --- a/runners/python/pytest_test.go +++ b/runners/python/pytest_test.go @@ -167,9 +167,79 @@ func TestRunPythonTestsStructuredToleratesIrregularFilesInCheckout(t *testing.T) assertDefaultRunnerLeftSourceClean(t, root) } -// TestSnapshotSourceTree exercises the snapshot copy directly: source files are -// reproduced, symlinks are preserved, but regenerable/VCS directories, prior -// build metadata, and irregular files never enter the snapshot. +// TestRunPythonTestsStructuredBuildsGitVersionedProject proves the read-only +// snapshot preserves .git so a build backend that derives its version from git +// (setuptools_scm) can build the project. Excluding .git failed the build with +// "unable to detect version ... not a git repository", env-erroring a run that +// should pass — while the checkout must still stay clean. +func TestRunPythonTestsStructuredBuildsGitVersionedProject(t *testing.T) { + if _, err := exec.LookPath("uv"); err != nil { + t.Fatalf("uv is required for the production Python runner: %v", err) + } + if _, err := exec.LookPath("git"); err != nil { + t.Fatalf("git is required for this test: %v", err) + } + root := t.TempDir() + write := func(path, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, path), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + write("pyproject.toml", `[build-system] +requires = ["setuptools>=68", "setuptools_scm>=8"] +build-backend = "setuptools.build_meta" + +[project] +name = "codefly-git-versioned-probe" +dynamic = ["version"] + +[tool.setuptools_scm] + +[tool.setuptools] +py-modules = ["git_versioned_probe"] +`) + write("git_versioned_probe.py", "VALUE = 'from-git-versioned-build'\n") + write("test_git_versioned.py", `import git_versioned_probe + +def test_project_built_from_git_version(): + assert git_versioned_probe.VALUE == "from-git-versioned-build" +`) + + for _, args := range [][]string{ + {"init"}, + {"config", "user.email", "test@codefly.dev"}, + {"config", "user.name", "codefly test"}, + {"add", "-A"}, + {"commit", "-m", "init"}, + {"tag", "-m", "release", "v1.2.3"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + run, err := RunPythonTestsStructured(ctx, root, nil, TestOptions{VerboseSet: true}) + if err != nil { + t.Fatalf("RunPythonTestsStructured: %v\n%s", err, run.RawOutput) + } + if run.EnvError != nil { + t.Fatalf("default adapter environment error: %s\n%s", run.EnvError.Detail, run.RawOutput) + } + if summary := run.LegacyTestSummary(); summary.Run != 1 || summary.Passed != 1 { + t.Fatalf("summary = %+v, want one passed test\n%s", summary, run.RawOutput) + } + assertDefaultRunnerLeftSourceClean(t, root) +} + +// TestSnapshotSourceTree exercises the snapshot copy directly: source files +// (including .git, which build backends may read) are reproduced and symlinks +// preserved, while regenerable virtualenv/cache directories, prior build +// metadata, and irregular files never enter the snapshot. func TestSnapshotSourceTree(t *testing.T) { src := t.TempDir() write := func(p, c string) { @@ -183,6 +253,9 @@ func TestSnapshotSourceTree(t *testing.T) { } write("pkg/module.py", "VALUE = 1\n") write("requirements.txt", "./dep\n") + write("uv.lock", "version = 1\n") + // .git must be preserved: build backends like setuptools_scm read it at + // build time to derive a dynamic version. Excluding it would fail the run. write(".git/config", "[core]\n") write(".venv/bin/python", "#!/bin/sh\n") write("pkg.egg-info/PKG-INFO", "Metadata-Version: 2.1\n") @@ -203,7 +276,7 @@ func TestSnapshotSourceTree(t *testing.T) { _, err := os.Lstat(filepath.Join(dst, p)) return err == nil } - for _, p := range []string{"pkg/module.py", "requirements.txt"} { + for _, p := range []string{"pkg/module.py", "requirements.txt", "uv.lock", ".git/config"} { if !present(p) { t.Errorf("snapshot missing source file %s", p) } @@ -214,13 +287,50 @@ func TestSnapshotSourceTree(t *testing.T) { } else if info.Mode()&os.ModeSymlink == 0 { t.Errorf("snapshot did not preserve pkg/link.py as a symlink") } - for _, p := range []string{".git", ".venv", "pkg.egg-info", "__pycache__", "runtime.sock"} { + for _, p := range []string{".venv", "pkg.egg-info", "__pycache__", "runtime.sock"} { if present(p) { t.Errorf("snapshot copied excluded entry %s", p) } } } +// TestSnapshotSourceTreeFollowsSymlinkedRoot proves the snapshot resolves a +// symlinked source root — a real deployment shape (see the symlinked-source-root +// handling in resource loading). filepath.WalkDir does not follow the root +// symlink; without resolution the snapshot would be a lone dangling symlink and +// the whole test run would execute in a non-existent directory. +func TestSnapshotSourceTreeFollowsSymlinkedRoot(t *testing.T) { + base := t.TempDir() + real := filepath.Join(base, "real") + if err := os.MkdirAll(filepath.Join(real, "pkg"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(real, "pkg", "module.py"), []byte("VALUE = 1\n"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(base, "link") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + + dst := filepath.Join(t.TempDir(), "snapshot") + if err := snapshotSourceTree(link, dst); err != nil { + t.Fatalf("snapshotSourceTree through symlinked root: %v", err) + } + + info, err := os.Stat(dst) + if err != nil || !info.IsDir() { + t.Fatalf("snapshot root is not a real directory (info=%v err=%v)", info, err) + } + got, err := os.ReadFile(filepath.Join(dst, "pkg", "module.py")) + if err != nil { + t.Fatalf("snapshot did not copy contents through symlinked root: %v", err) + } + if string(got) != "VALUE = 1\n" { + t.Errorf("copied content = %q, want %q", got, "VALUE = 1\n") + } +} + // TestScanPytestEvents_EmitsPerLine feeds realistic pytest verbose // output through scanPytestEvents and asserts the callback fires once // per progress line, in order.