mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-08 02:37:52 +02:00
Fix Go CLI parity and wheel packaging
This commit is contained in:
@@ -47,7 +47,7 @@ jobs:
|
||||
- name: Cross-compile Go binaries
|
||||
if: inputs.working-directory == 'libs/cli'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: make build-go-all
|
||||
run: make build-go-all GO_BIN_DIR=build/go-bin
|
||||
|
||||
- name: Run Go tests
|
||||
if: inputs.working-directory == 'libs/cli'
|
||||
@@ -89,11 +89,12 @@ jobs:
|
||||
plat=$(echo "$entry" | awk '{print $2}')
|
||||
ext=""
|
||||
if [[ "$key" == windows-* ]]; then ext=".exe"; fi
|
||||
binary="langgraph_cli/bin/langgraph-${key}${ext}"
|
||||
binary="build/go-bin/langgraph-${key}${ext}"
|
||||
echo "Building wheel for ${key} -> ${plat}..."
|
||||
LANGGRAPH_GO_BINARY="$binary" LANGGRAPH_WHEEL_PLAT="$plat" uv build --wheel
|
||||
done
|
||||
# Also build the sdist and pure-Python fallback wheel
|
||||
rm -rf langgraph_cli/bin
|
||||
uv build
|
||||
echo "All wheels built:"
|
||||
ls -lh dist/
|
||||
|
||||
@@ -24,6 +24,10 @@ class GoBinaryBuildHook(BuildHookInterface):
|
||||
PLUGIN_NAME = "go-binary"
|
||||
|
||||
def initialize(self, version: str, build_data: dict) -> None:
|
||||
bin_dir = Path("langgraph_cli/bin")
|
||||
if bin_dir.exists():
|
||||
shutil.rmtree(bin_dir)
|
||||
|
||||
binary_path = os.environ.get("LANGGRAPH_GO_BINARY")
|
||||
if not binary_path:
|
||||
return
|
||||
@@ -33,7 +37,6 @@ class GoBinaryBuildHook(BuildHookInterface):
|
||||
msg = f"LANGGRAPH_GO_BINARY points to missing file: {source}"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
bin_dir = Path("langgraph_cli/bin")
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Determine output name (langgraph or langgraph.exe)
|
||||
|
||||
@@ -475,16 +475,39 @@ func ValidateConfig(raw map[string]any) (map[string]any, error) {
|
||||
|
||||
// ValidateConfigFile loads a config file, validates it, and returns the result.
|
||||
func ValidateConfigFile(configPath string) (map[string]any, error) {
|
||||
raw, err := LoadRawConfigFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return validateConfigFile(configPath, raw)
|
||||
}
|
||||
|
||||
// LoadRawConfigFile loads a config file and requires the top-level JSON value
|
||||
// to be an object.
|
||||
func LoadRawConfigFile(configPath string) (map[string]any, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read config file: %w", err)
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
var rawAny any
|
||||
if err := json.Unmarshal(data, &rawAny); err != nil {
|
||||
return nil, fmt.Errorf("Invalid JSON in %s: %s", configPath, err.Error())
|
||||
}
|
||||
|
||||
raw, ok := rawAny.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(
|
||||
"Invalid config in %s: top-level JSON value must be an object.",
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func validateConfigFile(configPath string, raw map[string]any) (map[string]any, error) {
|
||||
validated, err := ValidateConfig(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -512,7 +535,10 @@ func validatePackageJSON(path string) error {
|
||||
|
||||
var pkg map[string]any
|
||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
||||
return fmt.Errorf("Invalid package.json: %s", err.Error())
|
||||
return fmt.Errorf(
|
||||
"Invalid package.json found in langgraph config directory %s: file is not valid JSON",
|
||||
path,
|
||||
)
|
||||
}
|
||||
|
||||
enginesRaw, ok := pkg["engines"]
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
osexec "os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -18,6 +19,19 @@ import (
|
||||
"github.com/langchain-ai/langgraph/libs/cli/internal/version"
|
||||
)
|
||||
|
||||
var runPythonSubprocess = func(
|
||||
pythonExe string,
|
||||
args []string,
|
||||
stdout io.Writer,
|
||||
stderr io.Writer,
|
||||
) error {
|
||||
cmd := osexec.Command(pythonExe, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
const helpText = `Usage: langgraph [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
LangGraph CLI
|
||||
@@ -229,17 +243,13 @@ func loadAndValidateConfig(configPath string, stderr io.Writer) (map[string]any,
|
||||
errPrint(stderr, fmt.Sprintf("Path '%s' does not exist.", configPath))
|
||||
return nil, nil, false
|
||||
}
|
||||
data, err := os.ReadFile(configPath)
|
||||
|
||||
raw, err := config.LoadRawConfigFile(configPath)
|
||||
if err != nil {
|
||||
errPrint(stderr, err.Error())
|
||||
return nil, nil, false
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
errPrint(stderr, fmt.Sprintf("Invalid JSON in %s: %s", configPath, err.Error()))
|
||||
return nil, nil, false
|
||||
}
|
||||
validated, err := config.ValidateConfig(raw)
|
||||
validated, err := config.ValidateConfigFile(configPath)
|
||||
if err != nil {
|
||||
errPrint(stderr, err.Error())
|
||||
return nil, nil, false
|
||||
@@ -289,15 +299,27 @@ Options:
|
||||
_, _ = fmt.Fprintf(stderr, "%sError: %s%s\n", colorRed, err, colorReset)
|
||||
return 1
|
||||
}
|
||||
var rawConfig map[string]any
|
||||
if err := json.Unmarshal(data, &rawConfig); err != nil {
|
||||
|
||||
var rawAny any
|
||||
if err := json.Unmarshal(data, &rawAny); err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "Error: Invalid JSON in %s: %s\n", configPath, err.Error())
|
||||
return 1
|
||||
}
|
||||
rawConfig, ok := rawAny.(map[string]any)
|
||||
if !ok {
|
||||
_, _ = fmt.Fprintf(
|
||||
stderr,
|
||||
"%sError: Invalid config in %s: top-level JSON value must be an object.%s\n",
|
||||
colorRed,
|
||||
configPath,
|
||||
colorReset,
|
||||
)
|
||||
return 1
|
||||
}
|
||||
|
||||
unknownWarnings := config.GetUnknownKeys(rawConfig)
|
||||
|
||||
validated, validErr := config.ValidateConfig(rawConfig)
|
||||
validated, validErr := config.ValidateConfigFile(configPath)
|
||||
if validErr != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "%sError: %s%s\n", colorRed, validErr, colorReset)
|
||||
if len(unknownWarnings) > 0 {
|
||||
@@ -683,29 +705,7 @@ Options:
|
||||
}
|
||||
}
|
||||
|
||||
// Dev command: subprocess back into Python.
|
||||
// The Go CLI handles argument parsing, but the actual dev server runs in Python.
|
||||
pythonExe := os.Getenv("LANGGRAPH_CALLING_PYTHON")
|
||||
if pythonExe == "" {
|
||||
// Try to find python in the environment
|
||||
for _, name := range []string{"python3", "python"} {
|
||||
if p, _, err := lgexec.RunCollect("which", []string{name}); err == nil && strings.TrimSpace(p) != "" {
|
||||
pythonExe = strings.TrimSpace(p)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if pythonExe == "" {
|
||||
errPrint(stderr, "Could not find Python interpreter. Set LANGGRAPH_CALLING_PYTHON.")
|
||||
return 1
|
||||
}
|
||||
|
||||
// Forward all args to the Python dev command
|
||||
pyArgs := append([]string{"-m", "langgraph_cli.cli", "dev"}, args...)
|
||||
if err := lgexec.Run(pythonExe, pyArgs, lgexec.RunOpts{Verbose: true}); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
return runPythonCLI("dev", args, stdout, stderr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -767,37 +767,33 @@ Available templates:
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func runDeploy(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
return runDeployMain(args, stdout, stderr)
|
||||
return runPythonCLI("deploy", args, stdout, stderr)
|
||||
}
|
||||
|
||||
func runPythonCLI(subcommand string, args []string, stdout, stderr io.Writer) int {
|
||||
pythonExe := os.Getenv("LANGGRAPH_CALLING_PYTHON")
|
||||
if pythonExe == "" {
|
||||
for _, name := range []string{"python3", "python"} {
|
||||
if p, _, err := lgexec.RunCollect("which", []string{name}); err == nil && strings.TrimSpace(p) != "" {
|
||||
pythonExe = strings.TrimSpace(p)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
switch args[0] {
|
||||
case "--help", "-h":
|
||||
_, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
[Beta] Build and deploy a LangGraph image to LangSmith Deployment.
|
||||
|
||||
Commands:
|
||||
list List LangSmith Deployments.
|
||||
revisions Manage deployment revisions.
|
||||
delete Delete a LangSmith Deployment.
|
||||
logs Fetch LangSmith Deployment logs.
|
||||
|
||||
Run 'langgraph deploy COMMAND --help' for more information on a command.
|
||||
|
||||
If no subcommand is given, deploys the current project.`)
|
||||
return 0
|
||||
case "list":
|
||||
return runDeployList(args[1:], stdout, stderr)
|
||||
case "revisions":
|
||||
return runDeployRevisions(args[1:], stdout, stderr)
|
||||
case "delete":
|
||||
return runDeployDelete(args[1:], stdout, stderr)
|
||||
case "logs":
|
||||
return runDeployLogs(args[1:], stdout, stderr)
|
||||
default:
|
||||
// No subcommand → main deploy flow
|
||||
return runDeployMain(args, stdout, stderr)
|
||||
if pythonExe == "" {
|
||||
errPrint(stderr, "Could not find Python interpreter. Set LANGGRAPH_CALLING_PYTHON.")
|
||||
return 1
|
||||
}
|
||||
|
||||
pyArgs := append([]string{"-m", "langgraph_cli.cli", subcommand}, args...)
|
||||
if err := runPythonSubprocess(pythonExe, pyArgs, stdout, stderr); err != nil {
|
||||
if exitErr, ok := err.(*osexec.ExitError); ok {
|
||||
return exitErr.ExitCode()
|
||||
}
|
||||
errPrint(stderr, err.Error())
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func resolveDeployClient(args []string, stderr io.Writer) (apiKey, hostURL string, extra []string) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package root
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -117,6 +118,22 @@ func TestRunValidateWithInvalidJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidateWithNonObjectJSON(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
path := writeTempConfig(t, `[]`)
|
||||
|
||||
exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "top-level JSON value must be an object") {
|
||||
t.Fatalf("expected stderr to mention object-shaped config, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidateDefaultConfigMissing(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -203,3 +220,68 @@ func TestRunValidateWithWarningsAndErrors(t *testing.T) {
|
||||
t.Fatalf("expected stderr to contain 'warning', got %q", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidateWithInvalidPackageJSON(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "langgraph.json")
|
||||
packagePath := filepath.Join(dir, "package.json")
|
||||
if err := os.WriteFile(
|
||||
configPath,
|
||||
[]byte(`{"node_version":"20","graphs":{"agent":"./agent.js:graph"}}`),
|
||||
0644,
|
||||
); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(packagePath, []byte(`{invalid json`), 0644); err != nil {
|
||||
t.Fatalf("failed to write package.json: %v", err)
|
||||
}
|
||||
|
||||
exitCode := Run([]string{"validate", "-c", configPath}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Invalid package.json found") {
|
||||
t.Fatalf("expected stderr to mention invalid package.json, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDeployDelegatesToPythonCLI(t *testing.T) {
|
||||
t.Setenv("LANGGRAPH_CALLING_PYTHON", "/custom/python")
|
||||
|
||||
originalRunPythonSubprocess := runPythonSubprocess
|
||||
t.Cleanup(func() {
|
||||
runPythonSubprocess = originalRunPythonSubprocess
|
||||
})
|
||||
|
||||
var gotPython string
|
||||
var gotArgs []string
|
||||
runPythonSubprocess = func(
|
||||
pythonExe string,
|
||||
args []string,
|
||||
stdout io.Writer,
|
||||
stderr io.Writer,
|
||||
) error {
|
||||
gotPython = pythonExe
|
||||
gotArgs = append([]string(nil), args...)
|
||||
return nil
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := Run([]string{"deploy", "--remote", "--install-command", "make deps"}, &stdout, &stderr)
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String())
|
||||
}
|
||||
if gotPython != "/custom/python" {
|
||||
t.Fatalf("expected delegated python to be /custom/python, got %q", gotPython)
|
||||
}
|
||||
expected := []string{"-m", "langgraph_cli.cli", "deploy", "--remote", "--install-command", "make deps"}
|
||||
if strings.Join(gotArgs, "\x00") != strings.Join(expected, "\x00") {
|
||||
t.Fatalf("unexpected delegated args: got %q want %q", gotArgs, expected)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user