The Go halves of this product cross-compile to Windows from any machine.
The engine does not: PyInstaller bundles the interpreter and the native
wheels of the machine it runs on, so a frozen engine can only be built on
Windows. That one fact was the entire reason no release had ever been
cut - two of the three binaries were ready for weeks.
behavision-setup installs the engine from source instead. It finds a
Python, builds a private virtual environment beside the database,
installs the engine into it, downloads the models, records how to start
it in the same agent.json the app reads, and then starts it and waits
for its API to answer.
That last step is the point. An installer that reports success and
leaves a shop with an engine that will not run has done worse than
failing: the failure surfaces later, to somebody who did not install it.
The trade, since whoever runs this is standing in a shop: it needs
Python and internet at install time and takes minutes, where a frozen
build needs neither. What it buys is a release that exists.
Details that are not incidental:
- `py -3` is tried before `python` on Windows. The launcher is what the
official installer puts on PATH; `python` there is often the Store
stub that prints an advert and exits 9009.
- a virtual environment, not the system Python. A shop PC may have
Python for something else, and the engine pins numpy below 2.0 -
installing that into a shared interpreter breaks the other thing
months later and silently.
- EngineExe is written absolute. The app resolves a relative one
against its install root under Program Files, where no interpreter
lives.
- pip's output is shown, not swallowed. When it fails on a proxy or a
missing build tool it says exactly what is wrong, and hiding that
leaves the operator with "setup failed" and nothing to act on.
- the console pauses before closing. Double-clicked from Explorer, a
program that finishes closes instantly and success and failure look
identical.
Verified as far as a Mac can: `pip install .` builds the wheel and
resolves every dependency, and `python -m behavision` then runs from
site-packages rather than the working directory - which is the mechanism
this depends on and had never been exercised, because the project has
only ever been run out of its own checkout.
NOT verified: any of it on Windows. Nothing here has run on the target
platform, and the `py -3` path and the ProgramData layout are exactly
where that will show.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pcn9asw19WGBfCEaHvNug6
332 lines
10 KiB
Go
332 lines
10 KiB
Go
// Command behavision-setup prepares a shop PC to run the recognition engine.
|
|
//
|
|
// It exists because the engine is Python and the rest of the product is Go.
|
|
// The Go halves cross-compile to Windows from any machine; the engine, frozen
|
|
// with PyInstaller, does not - PyInstaller bundles the interpreter and native
|
|
// wheels of the machine it runs on, so a frozen engine can only be built on
|
|
// Windows. That single fact was the whole reason a release could not be cut.
|
|
//
|
|
// So this installs the engine from source instead of shipping it frozen: find
|
|
// a Python, build a private virtual environment beside the database, install
|
|
// the engine into it, fetch the models, and record how to start it. Everything
|
|
// in the release can then be built anywhere.
|
|
//
|
|
// The trade, stated plainly because whoever runs this is standing in a shop:
|
|
// it needs Python and a working internet connection at install time, and it
|
|
// takes minutes rather than seconds. A frozen build needs neither. What it
|
|
// buys is a release that exists.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/loyaly/behavision-agent/pkg/config"
|
|
"github.com/loyaly/behavision-agent/pkg/paths"
|
|
)
|
|
|
|
// The engine needs 3.10; nothing here works below it and the failure would
|
|
// otherwise arrive as a syntax error deep inside a dependency.
|
|
const minMinor = 10
|
|
|
|
func main() {
|
|
if err := run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "\n Setup did not finish: %v\n\n", err)
|
|
pause()
|
|
os.Exit(1)
|
|
}
|
|
pause()
|
|
}
|
|
|
|
func run() error {
|
|
fmt.Println()
|
|
fmt.Println(" Behavision setup")
|
|
fmt.Println(" ----------------")
|
|
fmt.Println()
|
|
|
|
state := paths.StateRoot()
|
|
src, err := engineSource()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf(" engine source %s\n", src)
|
|
fmt.Printf(" install into %s\n", state)
|
|
fmt.Println()
|
|
|
|
if err := paths.EnsureState(); err != nil {
|
|
return fmt.Errorf("could not create %s: %w", state, err)
|
|
}
|
|
|
|
py, ver, err := findPython()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
step("Python", fmt.Sprintf("%s (%s)", ver, py))
|
|
|
|
venv := filepath.Join(state, "runtime")
|
|
if err := makeVenv(py, venv); err != nil {
|
|
return err
|
|
}
|
|
vpy := venvPython(venv)
|
|
step("Virtual environment", venv)
|
|
|
|
// --upgrade so re-running after a new release replaces the engine rather
|
|
// than leaving the old one in place and reporting success.
|
|
if err := pipInstall(vpy, src); err != nil {
|
|
return err
|
|
}
|
|
step("Engine and dependencies", "installed")
|
|
|
|
if err := runEngine(vpy, "setup-models"); err != nil {
|
|
return fmt.Errorf("downloading the recognition models: %w", err)
|
|
}
|
|
step("Recognition models", "downloaded")
|
|
|
|
if err := writeConfig(vpy); err != nil {
|
|
return err
|
|
}
|
|
step("Startup settings", filepath.Join(state, "agent.json"))
|
|
|
|
// Proving it starts is the point. An installer that reports success and
|
|
// leaves a shop with an engine that will not run has done worse than
|
|
// failing: the failure surfaces later, to someone who did not install it.
|
|
if err := smokeTest(vpy); err != nil {
|
|
return fmt.Errorf("the engine installed but would not start: %w", err)
|
|
}
|
|
step("Engine starts and answers", "verified")
|
|
|
|
fmt.Println()
|
|
fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.")
|
|
fmt.Println(" It appears in the system tray; right-click there to stop it.")
|
|
fmt.Println()
|
|
return nil
|
|
}
|
|
|
|
func step(label, detail string) {
|
|
fmt.Printf(" [ok] %-24s %s\n", label, detail)
|
|
}
|
|
|
|
// engineSource finds the Python source shipped beside this executable. Beside,
|
|
// not downloaded: the engine and the app must be the same release, and a
|
|
// version skew between them is the class of bug nobody can reproduce.
|
|
func engineSource() (string, error) {
|
|
candidates := []string{
|
|
filepath.Join(paths.InstallRoot(), "engine-src"),
|
|
filepath.Join(paths.InstallRoot(), "..", "engine-src"),
|
|
}
|
|
if wd, err := os.Getwd(); err == nil {
|
|
candidates = append(candidates, filepath.Join(wd, "engine-src"), wd)
|
|
}
|
|
for _, c := range candidates {
|
|
if _, err := os.Stat(filepath.Join(c, "pyproject.toml")); err == nil {
|
|
abs, _ := filepath.Abs(c)
|
|
return abs, nil
|
|
}
|
|
}
|
|
return "", errors.New("could not find the engine source (expected an " +
|
|
"engine-src folder with pyproject.toml beside this program). " +
|
|
"Unzip the whole release together rather than moving this file out of it")
|
|
}
|
|
|
|
// findPython returns the first interpreter that is new enough.
|
|
//
|
|
// `py -3` first on Windows: the launcher is what the official installer puts
|
|
// on PATH, and `python` there is often the Microsoft Store stub that prints an
|
|
// advert and exits 9009 instead of running anything.
|
|
func findPython() (string, string, error) {
|
|
type cand struct {
|
|
exe string
|
|
args []string
|
|
}
|
|
var cands []cand
|
|
if runtime.GOOS == "windows" {
|
|
cands = append(cands, cand{"py", []string{"-3"}})
|
|
}
|
|
cands = append(cands, cand{"python3", nil}, cand{"python", nil})
|
|
|
|
var tried []string
|
|
for _, c := range cands {
|
|
exe, err := exec.LookPath(c.exe)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
args := append(append([]string{}, c.args...), "-c",
|
|
"import sys;print('%d.%d'%sys.version_info[:2])")
|
|
out, err := exec.Command(exe, args...).Output()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
ver := strings.TrimSpace(string(out))
|
|
tried = append(tried, c.exe+" "+ver)
|
|
if major, minor, ok := parseVer(ver); ok && (major > 3 || (major == 3 && minor >= minMinor)) {
|
|
full := exe
|
|
if len(c.args) > 0 {
|
|
full = exe + " " + strings.Join(c.args, " ")
|
|
}
|
|
return full, "Python " + ver, nil
|
|
}
|
|
}
|
|
|
|
msg := "no Python 3.10 or newer was found on this PC.\n\n" +
|
|
" Install it from https://www.python.org/downloads/windows/\n" +
|
|
" and tick \"Add python.exe to PATH\" on the first screen,\n" +
|
|
" then run this again."
|
|
if len(tried) > 0 {
|
|
msg += "\n\n Found, but too old: " + strings.Join(tried, ", ")
|
|
}
|
|
return "", "", errors.New(msg)
|
|
}
|
|
|
|
func parseVer(s string) (int, int, bool) {
|
|
parts := strings.Split(s, ".")
|
|
if len(parts) < 2 {
|
|
return 0, 0, false
|
|
}
|
|
major, err1 := strconv.Atoi(parts[0])
|
|
minor, err2 := strconv.Atoi(parts[1])
|
|
return major, minor, err1 == nil && err2 == nil
|
|
}
|
|
|
|
// splitLauncher turns `py -3` back into a command and its arguments.
|
|
func splitLauncher(s string) (string, []string) {
|
|
f := strings.Fields(s)
|
|
if len(f) == 0 {
|
|
return s, nil
|
|
}
|
|
return f[0], f[1:]
|
|
}
|
|
|
|
func venvPython(venv string) string {
|
|
if runtime.GOOS == "windows" {
|
|
return filepath.Join(venv, "Scripts", "python.exe")
|
|
}
|
|
return filepath.Join(venv, "bin", "python")
|
|
}
|
|
|
|
// makeVenv builds the engine's own interpreter under the writable state root.
|
|
//
|
|
// A virtual environment rather than the system Python: a shop PC may have
|
|
// Python there for something else, and pinning numpy below 2.0 - which the
|
|
// engine requires - inside a shared interpreter is how you break the other
|
|
// thing months later, silently.
|
|
func makeVenv(py, venv string) error {
|
|
if _, err := os.Stat(venvPython(venv)); err == nil {
|
|
return nil // already built; pip below brings it up to date
|
|
}
|
|
exe, args := splitLauncher(py)
|
|
args = append(args, "-m", "venv", venv)
|
|
return stream(exec.Command(exe, args...), "creating the virtual environment")
|
|
}
|
|
|
|
func pipInstall(vpy, src string) error {
|
|
fmt.Println(" Installing the engine and its libraries. This downloads a few")
|
|
fmt.Println(" hundred megabytes and takes a while on a slow connection.")
|
|
fmt.Println()
|
|
if err := stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade",
|
|
"pip", "setuptools", "wheel"), "updating pip"); err != nil {
|
|
return err
|
|
}
|
|
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", src),
|
|
"installing the engine")
|
|
}
|
|
|
|
func runEngine(vpy string, args ...string) error {
|
|
full := append([]string{"-m", "behavision"}, args...)
|
|
return stream(exec.Command(vpy, full...), "running the engine")
|
|
}
|
|
|
|
// writeConfig records how to start the engine, in the same file and through
|
|
// the same type the app reads, so the two cannot disagree about it.
|
|
func writeConfig(vpy string) error {
|
|
path := paths.AgentConfig()
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
return fmt.Errorf("reading %s: %w", path, err)
|
|
}
|
|
// An absolute path: the app resolves a relative EngineExe against its own
|
|
// install root under Program Files, and the interpreter is not there.
|
|
cfg.EngineExe = vpy
|
|
cfg.EngineArgs = []string{"-m", "behavision", "run"}
|
|
if cfg.APIBase == "" {
|
|
cfg.APIBase = "http://127.0.0.1:8010"
|
|
}
|
|
return cfg.Save(path)
|
|
}
|
|
|
|
// smokeTest starts the engine exactly as the app will and waits for its API to
|
|
// answer. Any reply counts, including 401: the engine invents its own
|
|
// credential when none is configured, and a refusal proves it is serving.
|
|
func smokeTest(vpy string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run")
|
|
var log strings.Builder
|
|
cmd.Stdout, cmd.Stderr = &log, &log
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = cmd.Process.Kill()
|
|
_, _ = cmd.Process.Wait()
|
|
}()
|
|
|
|
client := &http.Client{Timeout: 3 * time.Second}
|
|
deadline := time.Now().Add(75 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
resp, err := client.Get("http://127.0.0.1:8010/api/health")
|
|
if err == nil {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
return nil
|
|
}
|
|
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
|
|
break
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
return fmt.Errorf("it did not answer within 75 seconds.\n\n%s",
|
|
tail(log.String(), 15))
|
|
}
|
|
|
|
func tail(s string, n int) string {
|
|
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
|
if len(lines) > n {
|
|
lines = lines[len(lines)-n:]
|
|
}
|
|
return " " + strings.Join(lines, "\n ")
|
|
}
|
|
|
|
// stream runs a command and shows its output. Shown, not swallowed: pip failing
|
|
// on a missing build tool prints exactly what is wrong, and hiding that leaves
|
|
// the operator with "setup failed" and nothing to act on.
|
|
func stream(cmd *exec.Cmd, what string) error {
|
|
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("%s failed: %w", what, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pause keeps the window open. Double-clicked from Explorer, a console program
|
|
// that finishes closes instantly and the operator sees nothing at all -
|
|
// success and failure look identical.
|
|
func pause() {
|
|
if runtime.GOOS != "windows" {
|
|
return
|
|
}
|
|
fmt.Print(" Press Enter to close. ")
|
|
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
|
|
}
|