Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 130 additions & 1 deletion apps/openant-cli/internal/python/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
package python

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"os/exec"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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()
Expand Down
Loading
Loading