diff --git a/apps/openant-cli/internal/python/runtime.go b/apps/openant-cli/internal/python/runtime.go index ba8d131..87f3a75 100644 --- a/apps/openant-cli/internal/python/runtime.go +++ b/apps/openant-cli/internal/python/runtime.go @@ -2,6 +2,8 @@ package python import ( + "crypto/sha256" + "encoding/hex" "fmt" "os" "os/exec" @@ -149,6 +151,20 @@ func checkPython(path string) (*RuntimeInfo, error) { // On success, it updates the RuntimeInfo to point to the venv Python. func CheckOpenantInstalled(pythonPath string) error { if isOpenantImportable(pythonPath) { + // openant is already installed. Record the current pyproject.toml + // hash if we don't have one yet so existing users don't trigger a + // spurious reinstall on first run after upgrade. Best-effort only. + if readStoredHash() == "" { + if corePath, err := findOpenantCore(); err == nil { + if h, err := hashFile(filepath.Join(corePath, "pyproject.toml")); err == nil { + if err := writeStoredHash(h); err != nil { + fmt.Fprintf(os.Stderr, + "warning: could not save dependency hash at %s: %v (next run may reinstall)\n", + depsHashPath(), err) + } + } + } + } return nil } @@ -194,6 +210,16 @@ func CheckOpenantInstalled(pythonPath string) error { ) } + // Save dependency hash so CheckDepsStale knows this is the baseline. + pyprojectPath := filepath.Join(corePath, "pyproject.toml") + if h, err := hashFile(pyprojectPath); err == nil { + if err := writeStoredHash(h); err != nil { + fmt.Fprintf(os.Stderr, + "warning: could not save dependency hash at %s: %v (next run may reinstall)\n", + depsHashPath(), err) + } + } + fmt.Fprintln(os.Stderr, "openant installed successfully.") return nil } @@ -216,13 +242,116 @@ func EnsureRuntime() (*RuntimeInfo, error) { vp := venvPython() if rt.Path != vp && fileExists(vp) && isOpenantImportable(vp) { if info, err := checkPython(vp); err == nil { - return info, nil + rt = info } } + // Check if dependencies have changed since last install. + if err := CheckDepsStale(rt.Path); err != nil { + return nil, err + } + return rt, nil } +// depsHashPath returns the path to the stored dependency hash inside the venv. +func depsHashPath() string { + return filepath.Join(venvDir(), ".deps-hash") +} + +// hashFile returns the hex-encoded SHA-256 of a file's contents. +func hashFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]), nil +} + +// readHashAt reads a stored hash from the given path, or "" if absent. +func readHashAt(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +// writeHashAt saves a hash to the given path, creating the parent directory +// if it does not already exist. +func writeHashAt(path, hash string) error { + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + } + return os.WriteFile(path, []byte(hash+"\n"), 0644) +} + +// readStoredHash reads the previously stored dependency hash, or "" if absent. +func readStoredHash() string { return readHashAt(depsHashPath()) } + +// writeStoredHash saves the dependency hash to the venv marker file. +func writeStoredHash(hash string) error { return writeHashAt(depsHashPath(), hash) } + +// depsStalenessAt inspects pyproject.toml at corePath and the hash stored at +// hashPath, and reports whether a reinstall is needed. The boolean is true +// when deps are stale (i.e. the hash differs and a reinstall is warranted). +// The caller is expected to skip the check on any error. +func depsStalenessAt(corePath, hashPath string) (stale bool, currentHash string, err error) { + pyprojectPath := filepath.Join(corePath, "pyproject.toml") + currentHash, err = hashFile(pyprojectPath) + if err != nil { + return false, "", err + } + return currentHash != readHashAt(hashPath), currentHash, nil +} + +// depsStaleness is the production wrapper around depsStalenessAt that uses +// the real venv hash path. +func depsStaleness(corePath string) (stale bool, currentHash string, err error) { + return depsStalenessAt(corePath, depsHashPath()) +} + +// CheckDepsStale checks if pyproject.toml has changed since the last install. +// If stale, it re-runs pip install -e and updates the stored hash. +// Returns nil if deps are up-to-date or were successfully refreshed. +func CheckDepsStale(pythonPath string) error { + corePath, err := findOpenantCore() + if err != nil { + // Can't find source — skip staleness check + return nil + } + + stale, currentHash, err := depsStaleness(corePath) + if err != nil { + // Can't read pyproject.toml — skip check + return nil + } + if !stale { + return nil // deps are up-to-date + } + + fmt.Fprintln(os.Stderr, "Dependencies changed, updating openant installation...") + if err := installOpenant(pythonPath, corePath); err != nil { + return fmt.Errorf( + "failed to update openant dependencies: %w\n"+ + "Try manually: %s -m pip install -e %s", + err, pythonPath, corePath, + ) + } + + // Store the new hash + if err := writeStoredHash(currentHash); err != nil { + // Non-fatal — install succeeded, just can't cache the hash + fmt.Fprintf(os.Stderr, "Warning: could not save dependency hash: %v\n", err) + } + + fmt.Fprintln(os.Stderr, "Dependencies updated successfully.") + return nil +} + // createVenv creates a new venv at ~/.openant/venv/ using the given Python. func createVenv(pythonPath string) error { dir := venvDir() diff --git a/apps/openant-cli/internal/python/runtime_test.go b/apps/openant-cli/internal/python/runtime_test.go new file mode 100644 index 0000000..662429f --- /dev/null +++ b/apps/openant-cli/internal/python/runtime_test.go @@ -0,0 +1,302 @@ +package python + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +// --------------------------------------------------------------------------- +// hashFile +// --------------------------------------------------------------------------- + +func TestHashFile_KnownContent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.toml") + content := []byte("[project]\nname = \"openant\"\n") + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatal(err) + } + + got, err := hashFile(path) + if err != nil { + t.Fatalf("hashFile returned error: %v", err) + } + + sum := sha256.Sum256(content) + want := hex.EncodeToString(sum[:]) + if got != want { + t.Errorf("hashFile = %q, want %q", got, want) + } +} + +func TestHashFile_EmptyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty") + if err := os.WriteFile(path, []byte{}, 0644); err != nil { + t.Fatal(err) + } + + got, err := hashFile(path) + if err != nil { + t.Fatalf("hashFile returned error: %v", err) + } + + sum := sha256.Sum256([]byte{}) + want := hex.EncodeToString(sum[:]) + if got != want { + t.Errorf("hashFile = %q, want %q", got, want) + } +} + +func TestHashFile_MissingFile(t *testing.T) { + _, err := hashFile(filepath.Join(t.TempDir(), "nonexistent")) + if err == nil { + t.Error("expected error for missing file, got nil") + } +} + +func TestHashFile_DifferentContent(t *testing.T) { + dir := t.TempDir() + pathA := filepath.Join(dir, "a.toml") + pathB := filepath.Join(dir, "b.toml") + os.WriteFile(pathA, []byte("version 1"), 0644) + os.WriteFile(pathB, []byte("version 2"), 0644) + + hashA, _ := hashFile(pathA) + hashB, _ := hashFile(pathB) + if hashA == hashB { + t.Error("different files should produce different hashes") + } +} + +// --------------------------------------------------------------------------- +// readStoredHash / writeStoredHash +// --------------------------------------------------------------------------- + +// readStoredHash / writeStoredHash delegate to readHashAt/writeHashAt with +// a path under the user's real ~/.openant/venv/. The tests exercise the +// underlying readHashAt/writeHashAt helpers directly to avoid touching the +// real venv directory. + +func TestWriteAndReadHashAt_RoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".deps-hash") + + hash := "abc123def456" + if err := writeHashAt(path, hash); err != nil { + t.Fatalf("writeHashAt: %v", err) + } + + got := readHashAt(path) + if got != hash { + t.Errorf("readHashAt = %q, want %q (trailing newline should be trimmed)", got, hash) + } +} + +func TestReadHashAt_MissingFile_ReturnsEmpty(t *testing.T) { + got := readHashAt(filepath.Join(t.TempDir(), "nonexistent")) + if got != "" { + t.Errorf("readHashAt missing file = %q, want \"\"", got) + } +} + +func TestReadHashAt_TrimsWhitespace(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".deps-hash") + if err := os.WriteFile(path, []byte(" abc\n\n"), 0644); err != nil { + t.Fatal(err) + } + if got := readHashAt(path); got != "abc" { + t.Errorf("readHashAt = %q, want %q", got, "abc") + } +} + +func TestReadStoredHash_DoesNotPanic(t *testing.T) { + // Smoke test: reading from the real ~/.openant/venv/.deps-hash must + // not panic regardless of whether the file exists. + _ = readStoredHash() +} + +func TestWriteHashAt_CreatesMissingParentDir(t *testing.T) { + dir := t.TempDir() + // nested directory that does not yet exist + path := filepath.Join(dir, "a", "b", ".deps-hash") + if err := writeHashAt(path, "deadbeef"); err != nil { + t.Fatalf("writeHashAt should create missing parents: %v", err) + } + if got := readHashAt(path); got != "deadbeef" { + t.Errorf("readHashAt after writeHashAt = %q, want %q", got, "deadbeef") + } +} + +// --------------------------------------------------------------------------- +// depsStalenessAt — covers the trigger detection logic without invoking pip +// --------------------------------------------------------------------------- + +// writeFakeCore creates a minimal pyproject.toml under a fake core dir and +// returns the core dir path. +func writeFakeCore(t *testing.T, contents string) string { + t.Helper() + core := t.TempDir() + if err := os.WriteFile(filepath.Join(core, "pyproject.toml"), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + return core +} + +func TestDepsStalenessAt_FreshState_NoHashStored_IsStale(t *testing.T) { + core := writeFakeCore(t, "[project]\nname = \"x\"\n") + hashPath := filepath.Join(t.TempDir(), ".deps-hash") + + stale, cur, err := depsStalenessAt(core, hashPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !stale { + t.Error("expected stale=true when no hash has been stored") + } + if cur == "" { + t.Error("expected non-empty current hash") + } +} + +func TestDepsStalenessAt_MatchingHash_NotStale(t *testing.T) { + core := writeFakeCore(t, "[project]\nname = \"x\"\n") + hashPath := filepath.Join(t.TempDir(), ".deps-hash") + + // First call: capture the hash and write it out. + _, cur, err := depsStalenessAt(core, hashPath) + if err != nil { + t.Fatalf("first call: %v", err) + } + if err := writeHashAt(hashPath, cur); err != nil { + t.Fatal(err) + } + + // Second call: hash matches, should not be stale. + stale, _, err := depsStalenessAt(core, hashPath) + if err != nil { + t.Fatalf("second call: %v", err) + } + if stale { + t.Error("expected stale=false when stored hash matches current") + } +} + +func TestDepsStalenessAt_ModifiedPyproject_IsStale(t *testing.T) { + core := writeFakeCore(t, "[project]\nname = \"x\"\nversion = \"0.1\"\n") + hashPath := filepath.Join(t.TempDir(), ".deps-hash") + + _, originalHash, err := depsStalenessAt(core, hashPath) + if err != nil { + t.Fatal(err) + } + if err := writeHashAt(hashPath, originalHash); err != nil { + t.Fatal(err) + } + + // Mutate pyproject.toml — simulating a `git pull` that bumped a dep. + if err := os.WriteFile( + filepath.Join(core, "pyproject.toml"), + []byte("[project]\nname = \"x\"\nversion = \"0.2\"\ndependencies = [\"requests\"]\n"), + 0644, + ); err != nil { + t.Fatal(err) + } + + stale, newHash, err := depsStalenessAt(core, hashPath) + if err != nil { + t.Fatal(err) + } + if !stale { + t.Error("expected stale=true after pyproject.toml was modified") + } + if newHash == originalHash { + t.Error("expected new hash to differ from original after content change") + } +} + +func TestDepsStalenessAt_MissingPyproject_ReturnsError(t *testing.T) { + core := t.TempDir() // no pyproject.toml inside + hashPath := filepath.Join(t.TempDir(), ".deps-hash") + + stale, _, err := depsStalenessAt(core, hashPath) + if err == nil { + t.Error("expected error when pyproject.toml is missing") + } + if stale { + t.Error("expected stale=false on error") + } +} + +func TestDepsStalenessAt_StoredHashEqualsEmpty_StillStale(t *testing.T) { + // If the hash file is present but empty (e.g. truncated write), the + // stored hash trims to "" and we should treat the deps as stale so the + // next run heals the state by reinstalling. + core := writeFakeCore(t, "[project]\nname = \"x\"\n") + hashPath := filepath.Join(t.TempDir(), ".deps-hash") + if err := os.WriteFile(hashPath, []byte("\n"), 0644); err != nil { + t.Fatal(err) + } + + stale, _, err := depsStalenessAt(core, hashPath) + if err != nil { + t.Fatal(err) + } + if !stale { + t.Error("expected stale=true when stored hash is empty") + } +} + +// --------------------------------------------------------------------------- +// CheckDepsStale — integration-style tests with temp dirs +// --------------------------------------------------------------------------- + +func TestCheckDepsStale_SkipsWhenCoreNotFound(t *testing.T) { + // CheckDepsStale should silently return nil when it can't find + // openant-core. We chdir to a temp dir so findOpenantCore fails. + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(origDir) }) + + err = CheckDepsStale("/nonexistent/python") + if err != nil { + t.Errorf("expected nil when core not found, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// fileExists +// --------------------------------------------------------------------------- + +func TestFileExists_True(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "exists.txt") + os.WriteFile(path, []byte("hi"), 0644) + + if !fileExists(path) { + t.Error("fileExists should return true for existing file") + } +} + +func TestFileExists_False_Missing(t *testing.T) { + if fileExists(filepath.Join(t.TempDir(), "nope")) { + t.Error("fileExists should return false for missing file") + } +} + +func TestFileExists_False_Directory(t *testing.T) { + dir := t.TempDir() + if fileExists(dir) { + t.Error("fileExists should return false for directories") + } +}