Files
2026-04-08 05:40:59 -07:00

1634 lines
45 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
// UvLockSourceEntry represents a single [tool.uv.sources] entry with its
// provenance (which pyproject.toml declared it, and from which directory).
type UvLockSourceEntry struct {
Name string
Value any
DeclaredRoot string // absolute path
PyprojectPath string
}
// UvLockPackage represents one workspace package discovered from pyproject.toml.
type UvLockPackage struct {
Name string
NormalizedName string
Root string // absolute path
PyprojectPath string
RawDependencySpecs any
RawUvTool any
PackageEnabled bool
DependencyNames []string
WorkspaceDependencies []string
}
// UvLockWorkspace holds the result of discovering all workspace packages.
type UvLockWorkspace struct {
RawRootSourceEntries any
PackagesByName map[string]*UvLockPackage
PackagesByRoot map[string]*UvLockPackage
}
// UvLockPlan is the fully resolved build plan for a uv-lock deployment.
type UvLockPlan struct {
ProjectRoot string
PyprojectPath string
UvLockPath string
Target *UvLockPackage
TargetRoot string
InstallOrder []*UvLockPackage
ContainerRoots map[string]string // hostRoot -> containerPath
WorkingDir string
AllWorkspaceRoots map[string]bool
}
// ---------------------------------------------------------------------------
// Minimal TOML parser
// ---------------------------------------------------------------------------
// parseTOML parses a subset of TOML needed for pyproject.toml files.
// Supports: string values, boolean values, arrays of strings, inline tables,
// and dotted table headers.
func parseTOML(data string) map[string]any {
result := make(map[string]any)
currentSection := result
currentPath := []string{}
lines := strings.Split(data, "\n")
for i := 0; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
// Skip comments and empty lines
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Table header: [section] or [section.subsection]
if strings.HasPrefix(line, "[") && !strings.HasPrefix(line, "[[") {
end := strings.Index(line, "]")
if end < 0 {
continue
}
sectionKey := strings.TrimSpace(line[1:end])
parts := strings.Split(sectionKey, ".")
currentPath = parts
currentSection = ensureNestedMap(result, parts)
continue
}
// Array of tables: [[section]]
if strings.HasPrefix(line, "[[") {
end := strings.Index(line, "]]")
if end < 0 {
continue
}
// We don't need array-of-tables for our use case, skip
continue
}
// Key = value
eqIdx := strings.Index(line, "=")
if eqIdx < 0 {
continue
}
key := strings.TrimSpace(line[:eqIdx])
valStr := strings.TrimSpace(line[eqIdx+1:])
// Handle dotted keys like tool.uv.package
keyParts := strings.Split(key, ".")
var target map[string]any
var finalKey string
if len(keyParts) > 1 {
target = ensureNestedMap(currentSection, keyParts[:len(keyParts)-1])
finalKey = keyParts[len(keyParts)-1]
} else {
target = currentSection
finalKey = key
}
// Multi-line arrays
if strings.HasPrefix(valStr, "[") && !strings.Contains(valStr, "]") {
// Collect continuation lines
for i+1 < len(lines) {
i++
cont := strings.TrimSpace(lines[i])
valStr += " " + cont
if strings.Contains(cont, "]") {
break
}
}
}
// Multi-line inline tables
if strings.HasPrefix(valStr, "{") && !strings.Contains(valStr, "}") {
for i+1 < len(lines) {
i++
cont := strings.TrimSpace(lines[i])
valStr += " " + cont
if strings.Contains(cont, "}") {
break
}
}
}
value := parseTOMLValue(valStr)
// Use the full path for context when resolving dotted keys in sections
_ = currentPath
target[finalKey] = value
}
return result
}
// ensureNestedMap navigates/creates nested maps for a dotted key path.
func ensureNestedMap(root map[string]any, parts []string) map[string]any {
current := root
for _, part := range parts {
if existing, ok := current[part]; ok {
if m, ok := existing.(map[string]any); ok {
current = m
} else {
// Overwrite non-map with map (shouldn't happen in valid TOML)
m := make(map[string]any)
current[part] = m
current = m
}
} else {
m := make(map[string]any)
current[part] = m
current = m
}
}
return current
}
// parseTOMLValue parses a single TOML value from a string.
func parseTOMLValue(s string) any {
s = strings.TrimSpace(s)
// Boolean
if s == "true" {
return true
}
if s == "false" {
return false
}
// String (double-quoted)
if strings.HasPrefix(s, "\"") {
return parseTOMLString(s)
}
// String (single-quoted / literal)
if strings.HasPrefix(s, "'") {
end := strings.LastIndex(s, "'")
if end > 0 {
return s[1:end]
}
return s[1:]
}
// Array
if strings.HasPrefix(s, "[") {
return parseTOMLArray(s)
}
// Inline table
if strings.HasPrefix(s, "{") {
return parseTOMLInlineTable(s)
}
// Number or other - return as string
// Strip trailing comments
if idx := strings.Index(s, " #"); idx >= 0 {
s = strings.TrimSpace(s[:idx])
}
return s
}
// parseTOMLString extracts a double-quoted TOML string.
func parseTOMLString(s string) string {
if len(s) < 2 || s[0] != '"' {
return s
}
// Find closing quote, handling escapes
result := strings.Builder{}
i := 1
for i < len(s) {
if s[i] == '\\' && i+1 < len(s) {
switch s[i+1] {
case '"':
result.WriteByte('"')
case '\\':
result.WriteByte('\\')
case 'n':
result.WriteByte('\n')
case 't':
result.WriteByte('\t')
default:
result.WriteByte('\\')
result.WriteByte(s[i+1])
}
i += 2
continue
}
if s[i] == '"' {
break
}
result.WriteByte(s[i])
i++
}
return result.String()
}
// parseTOMLArray parses a TOML array value like ["a", "b", "c"].
func parseTOMLArray(s string) []any {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "[") {
return nil
}
// Find matching close bracket
end := findMatchingBracket(s, 0, '[', ']')
if end < 0 {
end = len(s) - 1
}
inner := strings.TrimSpace(s[1:end])
if inner == "" {
return []any{}
}
var result []any
for _, item := range splitTOMLItems(inner) {
item = strings.TrimSpace(item)
if item == "" {
continue
}
result = append(result, parseTOMLValue(item))
}
return result
}
// parseTOMLInlineTable parses an inline table like { workspace = true }.
func parseTOMLInlineTable(s string) map[string]any {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "{") {
return nil
}
end := strings.LastIndex(s, "}")
if end < 0 {
end = len(s)
}
inner := strings.TrimSpace(s[1:end])
if inner == "" {
return map[string]any{}
}
result := make(map[string]any)
for _, pair := range splitTOMLItems(inner) {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
eqIdx := strings.Index(pair, "=")
if eqIdx < 0 {
continue
}
key := strings.TrimSpace(pair[:eqIdx])
val := strings.TrimSpace(pair[eqIdx+1:])
result[key] = parseTOMLValue(val)
}
return result
}
// splitTOMLItems splits comma-separated TOML items, respecting nesting.
func splitTOMLItems(s string) []string {
var items []string
depth := 0
inStr := false
strChar := byte(0)
start := 0
for i := 0; i < len(s); i++ {
ch := s[i]
if inStr {
if ch == '\\' {
i++ // skip escape
continue
}
if ch == strChar {
inStr = false
}
continue
}
if ch == '"' || ch == '\'' {
inStr = true
strChar = ch
continue
}
if ch == '[' || ch == '{' {
depth++
continue
}
if ch == ']' || ch == '}' {
depth--
continue
}
if ch == ',' && depth == 0 {
items = append(items, s[start:i])
start = i + 1
}
}
if start < len(s) {
items = append(items, s[start:])
}
return items
}
// findMatchingBracket finds the index of the matching close bracket.
func findMatchingBracket(s string, start int, open, close byte) int {
depth := 0
inStr := false
strChar := byte(0)
for i := start; i < len(s); i++ {
ch := s[i]
if inStr {
if ch == '\\' {
i++
continue
}
if ch == strChar {
inStr = false
}
continue
}
if ch == '"' || ch == '\'' {
inStr = true
strChar = ch
continue
}
if ch == open {
depth++
} else if ch == close {
depth--
if depth == 0 {
return i
}
}
}
return -1
}
// ---------------------------------------------------------------------------
// Core helpers
// ---------------------------------------------------------------------------
var normalizeNamePattern = regexp.MustCompile(`[-_.]+`)
// normalizePackageName replaces sequences of [-_.] with - and lowercases.
func normalizePackageName(name string) string {
return strings.ToLower(normalizeNamePattern.ReplaceAllString(name, "-"))
}
var depNamePattern = regexp.MustCompile(`^\s*([A-Za-z0-9][A-Za-z0-9._-]*)`)
// parseDependencyName extracts the package name from a PEP 508 string.
func parseDependencyName(dep, packageName, pyprojectPath string) (string, error) {
match := depNamePattern.FindStringSubmatch(dep)
if match == nil {
return "", fmt.Errorf(
"source.kind 'uv' only supports PEP 508 dependency strings "+
"with an explicit package name. Could not parse dependency "+
"%q in %s for package '%s'.",
dep, pyprojectPath, packageName)
}
return normalizePackageName(match[1]), nil
}
// loadPyproject reads and parses a pyproject.toml file.
func loadPyproject(path string) (map[string]any, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("could not read %s: %w", path, err)
}
return parseTOML(string(data)), nil
}
// getNestedMap navigates a nested map via dotted key path.
func getNestedMap(m map[string]any, keys ...string) map[string]any {
current := m
for _, k := range keys {
next, ok := current[k].(map[string]any)
if !ok {
return map[string]any{}
}
current = next
}
return current
}
// getNestedValue navigates a nested map and returns the final value.
func getNestedValue(m map[string]any, keys ...string) any {
if len(keys) == 0 {
return nil
}
current := m
for _, k := range keys[:len(keys)-1] {
next, ok := current[k].(map[string]any)
if !ok {
return nil
}
current = next
}
return current[keys[len(keys)-1]]
}
// ---------------------------------------------------------------------------
// Dependency resolution
// ---------------------------------------------------------------------------
// getDependencyNames parses and normalizes [project].dependencies.
func getDependencyNames(depSpecs any, packageName, pyprojectPath string) ([]string, error) {
if depSpecs == nil {
return nil, nil
}
arr, ok := depSpecs.([]any)
if !ok {
return nil, fmt.Errorf(
"source.kind 'uv' requires [project].dependencies to be a "+
"list of strings in %s.", pyprojectPath)
}
var names []string
for _, item := range arr {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf(
"source.kind 'uv' requires [project].dependencies to be a "+
"list of strings in %s.", pyprojectPath)
}
name, err := parseDependencyName(s, packageName, pyprojectPath)
if err != nil {
return nil, err
}
names = append(names, name)
}
return names, nil
}
// getUvLockPackageEnabled returns whether tool.uv.package is true (default: true).
func getUvLockPackageEnabled(pkg *UvLockPackage) (bool, error) {
uvTool := pkg.RawUvTool
if uvTool == nil {
return true, nil
}
uvMap, ok := uvTool.(map[string]any)
if !ok {
return false, fmt.Errorf(
"source.kind 'uv' requires [tool.uv] to be a table in %s.",
pkg.PyprojectPath)
}
pkgVal, exists := uvMap["package"]
if !exists {
return true, nil
}
b, ok := pkgVal.(bool)
if !ok {
return false, fmt.Errorf(
"source.kind 'uv' requires [tool.uv].package to be a boolean in %s.",
pkg.PyprojectPath)
}
return b, nil
}
// getUvLockSourceEntries merges root-level and package-level [tool.uv.sources].
func getUvLockSourceEntries(
pkg *UvLockPackage,
projectRoot, rootPyprojectPath string,
rawRootSourceEntries any,
) ([]UvLockSourceEntry, error) {
rootSources, ok := rawRootSourceEntries.(map[string]any)
if !ok {
return nil, fmt.Errorf(
"source.kind 'uv' requires [tool.uv.sources] to be a table in %s.",
rootPyprojectPath)
}
var pkgSources map[string]any
if uvMap, ok := pkg.RawUvTool.(map[string]any); ok {
if s, ok := uvMap["sources"].(map[string]any); ok {
pkgSources = s
} else if uvMap["sources"] != nil {
return nil, fmt.Errorf(
"source.kind 'uv' requires [tool.uv.sources] to be a table in %s.",
pkg.PyprojectPath)
}
}
// Build merged entries: root first, then package-level overrides
entryMap := make(map[string]UvLockSourceEntry)
for name, val := range rootSources {
entryMap[name] = UvLockSourceEntry{
Name: name,
Value: val,
DeclaredRoot: projectRoot,
PyprojectPath: rootPyprojectPath,
}
}
for name, val := range pkgSources {
entryMap[name] = UvLockSourceEntry{
Name: name,
Value: val,
DeclaredRoot: pkg.Root,
PyprojectPath: pkg.PyprojectPath,
}
}
entries := make([]UvLockSourceEntry, 0, len(entryMap))
for _, e := range entryMap {
entries = append(entries, e)
}
return entries, nil
}
// validateUvLockSourceEntry validates a single source entry and returns
// the set of workspace dependency names it references.
func validateUvLockSourceEntry(
sourceName string,
sourceValue any,
declaredRoot, pyprojectPath, projectRoot string,
pkgsByName, pkgsByRoot map[string]*UvLockPackage,
) (map[string]bool, error) {
wsDeps := make(map[string]bool)
// Recurse into lists
if arr, ok := sourceValue.([]any); ok {
for _, item := range arr {
sub, err := validateUvLockSourceEntry(
sourceName, item, declaredRoot, pyprojectPath, projectRoot,
pkgsByName, pkgsByRoot)
if err != nil {
return nil, err
}
for k := range sub {
wsDeps[k] = true
}
}
return wsDeps, nil
}
m, ok := sourceValue.(map[string]any)
if !ok {
return wsDeps, nil
}
normalizedSourceName := normalizePackageName(sourceName)
// workspace = true
if ws, ok := m["workspace"]; ok && ws == true {
pkg := pkgsByName[normalizedSourceName]
if pkg == nil {
return nil, fmt.Errorf(
"'%s' in %s is marked as `{ workspace = true }` but no matching "+
"workspace package was found under project_root (%s). Check that "+
"'%s' appears in [tool.uv.workspace].members.",
sourceName, pyprojectPath, projectRoot, sourceName)
}
enabled, err := getUvLockPackageEnabled(pkg)
if err != nil {
return nil, err
}
if !enabled {
return nil, fmt.Errorf(
"'%s' in %s is a workspace dependency but sets "+
"`tool.uv.package = false` in %s. "+
"Workspace dependencies must be buildable packages.",
sourceName, pyprojectPath, pkg.PyprojectPath)
}
wsDeps[pkg.NormalizedName] = true
}
// path = "..."
if pathRef, ok := m["path"]; ok {
pathStr, ok := pathRef.(string)
if ok {
if filepath.IsAbs(pathStr) || strings.HasPrefix(pathStr, "/") {
return nil, fmt.Errorf(
"'%s' in %s uses an absolute path (%s), which is not supported. "+
"Use a relative path or `{ workspace = true }` instead.",
sourceName, pyprojectPath, pathStr)
}
resolved, _ := filepath.Abs(filepath.Join(declaredRoot, pathStr))
resolved = filepath.Clean(resolved)
if !isEqualOrChild(resolved, projectRoot) {
return nil, fmt.Errorf(
"'%s' in %s uses a path source that resolves to %s, "+
"which is outside project_root (%s).",
sourceName, pyprojectPath, resolved, projectRoot)
}
pkg := pkgsByRoot[resolved]
if pkg == nil {
return nil, fmt.Errorf(
"'%s' in %s uses a path source that resolves to %s, "+
"which is not a workspace package under project_root (%s).",
sourceName, pyprojectPath, resolved, projectRoot)
}
if pkg.NormalizedName != normalizedSourceName {
return nil, fmt.Errorf(
"'%s' in %s points to %s, which defines package '%s'. "+
"The dependency name and the workspace package name must match.",
sourceName, pyprojectPath, resolved, pkg.Name)
}
enabled, err := getUvLockPackageEnabled(pkg)
if err != nil {
return nil, err
}
if !enabled {
return nil, fmt.Errorf(
"'%s' in %s resolves to workspace package '%s', which sets "+
"`tool.uv.package = false` in %s. "+
"Workspace dependencies must be buildable packages.",
sourceName, pyprojectPath, pkg.Name, pkg.PyprojectPath)
}
wsDeps[pkg.NormalizedName] = true
}
}
// Recurse into other dict values
for key, val := range m {
if key == "workspace" || key == "path" {
continue
}
sub, err := validateUvLockSourceEntry(
sourceName, val, declaredRoot, pyprojectPath, projectRoot,
pkgsByName, pkgsByRoot)
if err != nil {
return nil, err
}
for k := range sub {
wsDeps[k] = true
}
}
return wsDeps, nil
}
// validateUvLockPackage validates a package's dependencies and source entries.
func validateUvLockPackage(
pkg *UvLockPackage,
projectRoot, rootPyprojectPath string,
rawRootSourceEntries any,
pkgsByName, pkgsByRoot map[string]*UvLockPackage,
) error {
depNames, err := getDependencyNames(
pkg.RawDependencySpecs, pkg.Name, pkg.PyprojectPath)
if err != nil {
return err
}
depNameSet := make(map[string]bool, len(depNames))
for _, n := range depNames {
depNameSet[n] = true
}
wsDeps := make(map[string]bool)
sourceEntries, err := getUvLockSourceEntries(
pkg, projectRoot, rootPyprojectPath, rawRootSourceEntries)
if err != nil {
return err
}
for _, entry := range sourceEntries {
if !depNameSet[normalizePackageName(entry.Name)] {
continue
}
sub, err := validateUvLockSourceEntry(
entry.Name, entry.Value,
entry.DeclaredRoot, entry.PyprojectPath, projectRoot,
pkgsByName, pkgsByRoot)
if err != nil {
return err
}
for k := range sub {
wsDeps[k] = true
}
}
enabled, err := getUvLockPackageEnabled(pkg)
if err != nil {
return err
}
pkg.PackageEnabled = enabled
pkg.DependencyNames = depNames
// Filter to workspace deps preserving dependency order
var wsDepOrdered []string
for _, n := range depNames {
if wsDeps[n] {
wsDepOrdered = append(wsDepOrdered, n)
}
}
pkg.WorkspaceDependencies = wsDepOrdered
return nil
}
// ---------------------------------------------------------------------------
// Workspace discovery
// ---------------------------------------------------------------------------
// discoverWorkspacePackages parses the root pyproject.toml, globs workspace
// members, and returns the full UvLockWorkspace.
func discoverWorkspacePackages(projectRoot, pyprojectPath string) (*UvLockWorkspace, error) {
rootData, err := loadPyproject(pyprojectPath)
if err != nil {
return nil, err
}
rootSourceEntries := getNestedValue(rootData, "tool", "uv", "sources")
if rootSourceEntries == nil {
rootSourceEntries = map[string]any{}
}
var candidateRoots []string
// Root project itself (if it has [project].name)
rootProject := getNestedMap(rootData, "project")
if name, _ := rootProject["name"].(string); name != "" {
candidateRoots = append(candidateRoots, projectRoot)
}
// Workspace members
workspaceMembers := getNestedValue(rootData, "tool", "uv", "workspace", "members")
if workspaceMembers != nil {
membersList, ok := workspaceMembers.([]any)
if !ok {
return nil, fmt.Errorf(
"source.kind 'uv' requires [tool.uv.workspace].members to be a list.")
}
for _, patternAny := range membersList {
pattern, ok := patternAny.(string)
if !ok {
return nil, fmt.Errorf(
"source.kind 'uv' requires every [tool.uv.workspace].members " +
"entry to be a string.")
}
globPattern := filepath.Join(projectRoot, pattern)
matches, err := filepath.Glob(globPattern)
if err != nil {
// Invalid glob pattern; skip
continue
}
sort.Strings(matches)
for _, match := range matches {
info, err := os.Stat(match)
if err != nil {
continue
}
pkgRoot := match
if !info.IsDir() {
pkgRoot = filepath.Dir(match)
}
pkgRoot, _ = filepath.Abs(pkgRoot)
pkgRoot = filepath.Clean(pkgRoot)
pyprojectFile := filepath.Join(pkgRoot, "pyproject.toml")
if fi, err := os.Stat(pyprojectFile); err == nil && !fi.IsDir() {
candidateRoots = append(candidateRoots, pkgRoot)
}
}
}
}
// Deduplicate while preserving order
seen := make(map[string]bool)
var uniqueRoots []string
for _, r := range candidateRoots {
if !seen[r] {
seen[r] = true
uniqueRoots = append(uniqueRoots, r)
}
}
// Parse each member
var packages []*UvLockPackage
for _, pkgRoot := range uniqueRoots {
memberPyprojectPath := filepath.Join(pkgRoot, "pyproject.toml")
pyData, err := loadPyproject(memberPyprojectPath)
if err != nil {
return nil, err
}
projectData := getNestedMap(pyData, "project")
pkgName, _ := projectData["name"].(string)
if pkgName == "" {
return nil, fmt.Errorf(
"source.kind 'uv' requires every workspace package to define "+
"[project].name in %s.", memberPyprojectPath)
}
packages = append(packages, &UvLockPackage{
Name: pkgName,
NormalizedName: normalizePackageName(pkgName),
Root: pkgRoot,
PyprojectPath: memberPyprojectPath,
RawDependencySpecs: projectData["dependencies"],
RawUvTool: getNestedValue(pyData, "tool", "uv"),
})
}
pkgsByName := make(map[string]*UvLockPackage, len(packages))
pkgsByRoot := make(map[string]*UvLockPackage, len(packages))
for _, pkg := range packages {
if existing, ok := pkgsByName[pkg.NormalizedName]; ok {
return nil, fmt.Errorf(
"source.kind 'uv' requires unique workspace package names, "+
"but both %s and %s define '%s'.",
existing.PyprojectPath, pkg.PyprojectPath, pkg.Name)
}
pkgsByName[pkg.NormalizedName] = pkg
pkgsByRoot[pkg.Root] = pkg
}
return &UvLockWorkspace{
RawRootSourceEntries: rootSourceEntries,
PackagesByName: pkgsByName,
PackagesByRoot: pkgsByRoot,
}, nil
}
// ---------------------------------------------------------------------------
// Target inference
// ---------------------------------------------------------------------------
// inferTargetPackage determines which workspace package is the deployment target.
func inferTargetPackage(
configRoot, projectRoot string,
source map[string]any,
pkgsByName, pkgsByRoot map[string]*UvLockPackage,
) (*UvLockPackage, error) {
if pkgNameAny, exists := source["package"]; exists {
pkgName, ok := pkgNameAny.(string)
if !ok || strings.TrimSpace(pkgName) == "" {
return nil, fmt.Errorf("`source.package` must be a non-empty string.")
}
target := pkgsByName[normalizePackageName(pkgName)]
if target == nil {
available := sortedPackageNames(pkgsByName)
return nil, fmt.Errorf(
"Could not find source.package '%s' in the uv project at %s. "+
"It must match a [project].name from one of the discovered "+
"packages. Available packages: %s.",
pkgName, projectRoot, available)
}
return target, nil
}
// Find containing packages (sorted by depth, deepest first)
type pkgWithDepth struct {
pkg *UvLockPackage
depth int
}
var containing []pkgWithDepth
for root, pkg := range pkgsByRoot {
if configRoot == root || isEqualOrChild(configRoot, root) {
parts := strings.Split(root, string(filepath.Separator))
containing = append(containing, pkgWithDepth{pkg: pkg, depth: len(parts)})
}
}
sort.Slice(containing, func(i, j int) bool {
return containing[i].depth > containing[j].depth
})
if len(containing) > 0 {
target := containing[0].pkg
if target.Root != projectRoot ||
len(pkgsByName) == 1 ||
configRoot == projectRoot {
return target, nil
}
}
if len(pkgsByName) == 1 {
for _, pkg := range pkgsByName {
return pkg, nil
}
}
available := sortedPackageNames(pkgsByName)
return nil, fmt.Errorf(
"source.package is required because source.root resolves to a uv "+
"workspace with multiple packages and no unique target package could be "+
"inferred from langgraph.json at %s. Available packages: %s. "+
"Move langgraph.json into the target package or set source.package.",
configRoot, available)
}
// ---------------------------------------------------------------------------
// Container path mapping
// ---------------------------------------------------------------------------
const containerWorkspaceRoot = "/deps/workspace"
// containerRootForPackage maps a package root to its container path.
func containerRootForPackage(projectRoot, packageRoot string) string {
rel, err := filepath.Rel(projectRoot, packageRoot)
if err != nil || rel == "." {
return containerWorkspaceRoot
}
// Use forward slashes for container paths
return containerWorkspaceRoot + "/" + filepath.ToSlash(rel)
}
// resolveUvLockContainerPath maps a host path to a container path using the plan.
func resolveUvLockContainerPath(hostPath string, plan *UvLockPlan) string {
// Sort by depth (deepest first) so more specific roots match first
type rootEntry struct {
root string
path string
}
var entries []rootEntry
for root, cpath := range plan.ContainerRoots {
entries = append(entries, rootEntry{root: root, path: cpath})
}
sort.Slice(entries, func(i, j int) bool {
return len(strings.Split(entries[i].root, string(filepath.Separator))) >
len(strings.Split(entries[j].root, string(filepath.Separator)))
})
for _, entry := range entries {
if hostPath == entry.root || isEqualOrChild(hostPath, entry.root) {
// Guard against workspace root matching unrelated members
if len(plan.AllWorkspaceRoots) > 0 &&
pathInUnrelatedMember(hostPath, entry.root, plan) {
continue
}
rel, err := filepath.Rel(entry.root, hostPath)
if err != nil {
continue
}
if rel == "." {
return entry.path
}
return entry.path + "/" + filepath.ToSlash(rel)
}
}
return ""
}
// pathInUnrelatedMember returns true if hostPath is inside a workspace member
// that is NOT in the closure (container_roots).
func pathInUnrelatedMember(hostPath, matchedRoot string, plan *UvLockPlan) bool {
for wsRoot := range plan.AllWorkspaceRoots {
if wsRoot == matchedRoot {
continue
}
if isEqualOrChild(matchedRoot, wsRoot) {
// matchedRoot is inside wsRoot -> wsRoot is less specific -> skip
continue
}
if hostPath == wsRoot || isEqualOrChild(hostPath, wsRoot) {
if _, inClosure := plan.ContainerRoots[wsRoot]; !inClosure {
return true
}
}
}
return false
}
// ---------------------------------------------------------------------------
// Copy items for workspace packages
// ---------------------------------------------------------------------------
// uvLockPackageCopyItems returns (source, destination) pairs for COPY/ADD.
// source is relative to projectRoot; destination is the container path.
func uvLockPackageCopyItems(pkg *UvLockPackage, plan *UvLockPlan) ([][2]string, error) {
if pkg.Root != plan.ProjectRoot {
rel, err := filepath.Rel(plan.ProjectRoot, pkg.Root)
if err != nil {
return nil, err
}
return [][2]string{{filepath.ToSlash(rel), plan.ContainerRoots[pkg.Root]}}, nil
}
// Root package: enumerate entries, skipping workspace member roots
rootContainer := plan.ContainerRoots[pkg.Root]
wsMemberRoots := make(map[string]bool)
for wsRoot := range plan.AllWorkspaceRoots {
if wsRoot != plan.ProjectRoot {
wsMemberRoots[wsRoot] = true
}
}
var iterEntries func(currentDir string) ([][2]string, error)
iterEntries = func(currentDir string) ([][2]string, error) {
dirEntries, err := os.ReadDir(currentDir)
if err != nil {
return nil, err
}
// Sort for deterministic output
sort.Slice(dirEntries, func(i, j int) bool {
return dirEntries[i].Name() < dirEntries[j].Name()
})
var result [][2]string
for _, entry := range dirEntries {
childPath := filepath.Join(currentDir, entry.Name())
childAbs, _ := filepath.Abs(childPath)
if wsMemberRoots[childAbs] {
continue
}
// Check if any workspace member is a descendant
hasDescendantMember := false
if entry.IsDir() {
for wsRoot := range wsMemberRoots {
if isEqualOrChild(wsRoot, childAbs) && wsRoot != childAbs {
hasDescendantMember = true
break
}
}
}
if entry.IsDir() && hasDescendantMember {
sub, err := iterEntries(childAbs)
if err != nil {
return nil, err
}
result = append(result, sub...)
continue
}
relChild, err := filepath.Rel(plan.ProjectRoot, childAbs)
if err != nil {
continue
}
relPosix := filepath.ToSlash(relChild)
result = append(result, [2]string{
relPosix,
rootContainer + "/" + relPosix,
})
}
return result, nil
}
return iterEntries(plan.ProjectRoot)
}
// ---------------------------------------------------------------------------
// Plan construction
// ---------------------------------------------------------------------------
// planUvLockWorkspace is the main planning function: resolve paths, discover
// workspace, infer target, validate, build install order, compute container paths.
func planUvLockWorkspace(configPath string, config map[string]any) (*UvLockPlan, error) {
configPathAbs, err := filepath.Abs(configPath)
if err != nil {
return nil, err
}
configRoot := filepath.Dir(configPathAbs)
source, _ := config["source"].(map[string]any)
root := "."
if r, ok := source["root"].(string); ok && strings.TrimSpace(r) != "" {
root = r
}
projectRoot, _ := filepath.Abs(filepath.Join(configRoot, root))
projectRoot = filepath.Clean(projectRoot)
pyprojectPath := filepath.Join(projectRoot, "pyproject.toml")
uvLockPath := filepath.Join(projectRoot, "uv.lock")
if _, err := os.Stat(uvLockPath); os.IsNotExist(err) {
return nil, fmt.Errorf(
"No uv.lock found at %s. Your langgraph.json sets "+
"source.root=%q, which resolves to "+
"%s. Make sure this is the directory where you run "+
"`uv lock` (it should contain both pyproject.toml and uv.lock).",
uvLockPath, root, projectRoot)
}
if _, err := os.Stat(pyprojectPath); os.IsNotExist(err) {
return nil, fmt.Errorf(
"No pyproject.toml found at %s. Your langgraph.json "+
"sets source.root=%q, which resolves to "+
"%s. This should be your uv workspace root.",
pyprojectPath, root, projectRoot)
}
workspace, err := discoverWorkspacePackages(projectRoot, pyprojectPath)
if err != nil {
return nil, err
}
target, err := inferTargetPackage(
configRoot, projectRoot, source,
workspace.PackagesByName, workspace.PackagesByRoot)
if err != nil {
return nil, err
}
if err := validateUvLockPackage(
target, projectRoot, pyprojectPath,
workspace.RawRootSourceEntries,
workspace.PackagesByName, workspace.PackagesByRoot,
); err != nil {
return nil, err
}
if !target.PackageEnabled {
return nil, fmt.Errorf(
"'%s' has `tool.uv.package = false` in %s, so it cannot be "+
"deployed. Either remove that setting or point `source.package` "+
"at a different workspace member.",
target.Name, target.PyprojectPath)
}
// Build install order via DFS
var installOrder []*UvLockPackage
visited := make(map[string]bool)
validated := map[string]bool{target.NormalizedName: true}
var visit func(pkg *UvLockPackage) error
visit = func(pkg *UvLockPackage) error {
if visited[pkg.NormalizedName] {
return nil
}
visited[pkg.NormalizedName] = true
if !validated[pkg.NormalizedName] {
if err := validateUvLockPackage(
pkg, projectRoot, pyprojectPath,
workspace.RawRootSourceEntries,
workspace.PackagesByName, workspace.PackagesByRoot,
); err != nil {
return err
}
validated[pkg.NormalizedName] = true
}
for _, depName := range pkg.WorkspaceDependencies {
dep := workspace.PackagesByName[depName]
if dep != nil {
if err := visit(dep); err != nil {
return err
}
}
}
installOrder = append(installOrder, pkg)
return nil
}
if err := visit(target); err != nil {
return nil, err
}
// Container roots for the install closure
containerRoots := make(map[string]string, len(installOrder))
for _, pkg := range installOrder {
containerRoots[pkg.Root] = containerRootForPackage(projectRoot, pkg.Root)
}
// All workspace roots (for exclusion logic)
allWsRoots := make(map[string]bool)
for _, pkg := range workspace.PackagesByName {
allWsRoots[pkg.Root] = true
}
// Determine working dir by resolving configRoot
tempPlan := &UvLockPlan{
ProjectRoot: projectRoot,
PyprojectPath: pyprojectPath,
UvLockPath: uvLockPath,
Target: target,
TargetRoot: target.Root,
InstallOrder: installOrder,
ContainerRoots: containerRoots,
WorkingDir: containerRootForPackage(projectRoot, target.Root),
AllWorkspaceRoots: allWsRoots,
}
workingDir := resolveUvLockContainerPath(configRoot, tempPlan)
if workingDir == "" {
workingDir = containerRoots[target.Root]
}
return &UvLockPlan{
ProjectRoot: projectRoot,
PyprojectPath: pyprojectPath,
UvLockPath: uvLockPath,
Target: target,
TargetRoot: target.Root,
InstallOrder: installOrder,
ContainerRoots: containerRoots,
WorkingDir: workingDir,
AllWorkspaceRoots: allWsRoots,
}, nil
}
// ---------------------------------------------------------------------------
// Import path rewriting
// ---------------------------------------------------------------------------
// rewriteUvLockImportPath rewrites a "module:attr" import string so that the
// module path points to the correct container location.
func rewriteUvLockImportPath(
configPath, importStr string, plan *UvLockPlan, label string,
) (string, error) {
parts := strings.SplitN(importStr, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", fmt.Errorf(
"Import string %q must be in format \"<module>:<attribute>\".",
importStr)
}
moduleStr := parts[0]
attrStr := parts[1]
if !strings.Contains(moduleStr, "/") && !strings.Contains(moduleStr, "\\") {
return importStr, nil
}
configDir := filepath.Dir(configPath)
resolved, _ := filepath.Abs(filepath.Join(configDir, moduleStr))
resolved = filepath.Clean(resolved)
info, err := os.Stat(resolved)
if err != nil {
return "", fmt.Errorf("Could not find %s: %s", label, resolved)
}
if info.IsDir() {
return "", fmt.Errorf("%s must be a file: %s",
strings.ToUpper(label[:1])+label[1:], resolved)
}
containerPath := resolveUvLockContainerPath(resolved, plan)
if containerPath == "" {
var copiedDirs []string
for _, pkg := range plan.InstallOrder {
rel, err := filepath.Rel(plan.ProjectRoot, pkg.Root)
if err != nil || rel == "." {
copiedDirs = append(copiedDirs, ".")
} else {
copiedDirs = append(copiedDirs, filepath.ToSlash(rel))
}
}
return "", fmt.Errorf(
"%s '%s' resolves to %s, which is not inside the target "+
"package '%s' or any of its workspace dependencies. Only these "+
"directories are copied into the container: %s. If this file "+
"lives in another workspace package, add it as a dependency of "+
"'%s' with `{ workspace = true }` in [tool.uv.sources].",
strings.ToUpper(label[:1])+label[1:],
importStr, resolved, plan.Target.Name,
strings.Join(copiedDirs, ", "), plan.Target.Name)
}
return containerPath + ":" + attrStr, nil
}
// updateUvLockGraphPaths rewrites graph import paths for uv-lock mode.
func updateUvLockGraphPaths(configPath string, config map[string]any, plan *UvLockPlan) error {
graphs, _ := config["graphs"].(map[string]any)
for graphID, data := range graphs {
switch v := data.(type) {
case map[string]any:
pathStr, ok := v["path"].(string)
if !ok || pathStr == "" {
return fmt.Errorf(
"Graph '%s' must contain a 'path' key if it is a dictionary.",
graphID)
}
rewritten, err := rewriteUvLockImportPath(
configPath, pathStr, plan, fmt.Sprintf("graph '%s'", graphID))
if err != nil {
return err
}
v["path"] = rewritten
case string:
rewritten, err := rewriteUvLockImportPath(
configPath, v, plan, fmt.Sprintf("graph '%s'", graphID))
if err != nil {
return err
}
graphs[graphID] = rewritten
default:
return fmt.Errorf(
"Graph '%s' must be a string or a dictionary with a 'path' key.",
graphID)
}
}
return nil
}
// updateUvLockComponentPath rewrites a single section.key import path.
func updateUvLockComponentPath(
configPath string, config map[string]any, plan *UvLockPlan,
section, key, label string,
) error {
sectionMap, ok := config[section].(map[string]any)
if !ok {
return nil
}
pathStr, ok := sectionMap[key].(string)
if !ok || pathStr == "" {
return nil
}
rewritten, err := rewriteUvLockImportPath(configPath, pathStr, plan, label)
if err != nil {
return err
}
sectionMap[key] = rewritten
return nil
}
// updateUvLockUIPaths rewrites UI file paths for uv-lock mode.
func updateUvLockUIPaths(configPath string, config map[string]any, plan *UvLockPlan) error {
ui, ok := config["ui"].(map[string]any)
if !ok {
return nil
}
configDir := filepath.Dir(configPath)
for uiName, pathAny := range ui {
pathStr, ok := pathAny.(string)
if !ok {
continue
}
resolved, _ := filepath.Abs(filepath.Join(configDir, pathStr))
resolved = filepath.Clean(resolved)
info, err := os.Stat(resolved)
if err != nil {
return fmt.Errorf("Could not find ui '%s': %s", uiName, resolved)
}
if info.IsDir() {
return fmt.Errorf("Ui '%s' must be a file: %s", uiName, resolved)
}
containerPath := resolveUvLockContainerPath(resolved, plan)
if containerPath == "" {
var copiedDirs []string
for _, pkg := range plan.InstallOrder {
rel, err := filepath.Rel(plan.ProjectRoot, pkg.Root)
if err != nil || rel == "." {
copiedDirs = append(copiedDirs, ".")
} else {
copiedDirs = append(copiedDirs, filepath.ToSlash(rel))
}
}
return fmt.Errorf(
"Ui '%s' resolves to %s, which is not inside the target "+
"package '%s' or any of its workspace dependencies. Only these "+
"directories are copied into the container: %s. If this file "+
"lives in another workspace package, add it as a dependency of "+
"'%s' with `{ workspace = true }` in [tool.uv.sources].",
uiName, resolved, plan.Target.Name,
strings.Join(copiedDirs, ", "), plan.Target.Name)
}
ui[uiName] = containerPath
}
return nil
}
// ---------------------------------------------------------------------------
// Dockerfile generation
// ---------------------------------------------------------------------------
// PythonConfigToDockerUVLock generates a Dockerfile and additional build contexts
// for a Python-based LangGraph configuration using uv lock mode.
func PythonConfigToDockerUVLock(
configPath string,
config map[string]any,
baseImage string,
apiVersion string,
buildToolsToUninstall []string,
) (string, map[string]string, error) {
if !ImageSupportsUV(baseImage) {
return "", nil, fmt.Errorf(
"source.kind 'uv' requires a base image with uv support " +
"(langchain/langgraph-api >= 0.2.47)")
}
configPathAbs, _ := filepath.Abs(configPath)
configRoot := filepath.Dir(configPathAbs)
installCmd := "uv pip install --system"
_, globalReqsPipInstall, pipConfigFileStr := buildPythonInstallCommands(config, installCmd)
plan, err := planUvLockWorkspace(configPath, config)
if err != nil {
return "", nil, err
}
// Rewrite graph paths
if err := updateUvLockGraphPaths(configPathAbs, config, plan); err != nil {
return "", nil, err
}
// Rewrite component paths
for _, sc := range [][3]string{
{"auth", "path", "auth.path"},
{"encryption", "path", "encryption.path"},
{"checkpointer", "path", "checkpointer.path"},
{"http", "app", "http.app"},
} {
if err := updateUvLockComponentPath(
configPathAbs, config, plan, sc[0], sc[1], sc[2]); err != nil {
return "", nil, err
}
}
// Rewrite UI paths
if err := updateUvLockUIPaths(configPathAbs, config, plan); err != nil {
return "", nil, err
}
// Additional contexts
additionalContexts := make(map[string]string)
var workspaceContextName string
if plan.ProjectRoot != configRoot && !isEqualOrChild(plan.ProjectRoot, configRoot) {
workspaceContextName = "uv-workspace-root"
additionalContexts[workspaceContextName] = plan.ProjectRoot
}
copyFromProjectRoot := func(relativePath, destination string) string {
if workspaceContextName != "" {
source := relativePath
if source == "" || source == "." {
source = "."
}
return fmt.Sprintf("COPY --from=%s %s %s", workspaceContextName, source, destination)
}
sourcePath := filepath.Join(plan.ProjectRoot, relativePath)
relSource, _ := filepath.Rel(configRoot, sourcePath)
return fmt.Sprintf("ADD %s %s", filepath.ToSlash(relSource), destination)
}
uvExportProjectDir := "/tmp/uv_export/project"
envVars := BuildRuntimeEnvVars(config)
imageStr := DockerTag(config, baseImage, apiVersion)
var lines []string
if len(additionalContexts) > 0 {
lines = append(lines, "# syntax=docker/dockerfile:1.4", "")
}
lines = append(lines, fmt.Sprintf("FROM %s", imageStr), "")
// dockerfile_lines
dfLines := configSlice(config, "dockerfile_lines")
if len(dfLines) > 0 {
for _, l := range dfLines {
if s, ok := l.(string); ok && s != "" {
lines = append(lines, s)
}
}
lines = append(lines, "")
}
// install node
nv, _ := config["node_version"].(string)
if (config["ui"] != nil || nv != "") && plan.WorkingDir != "" {
lines = append(lines, "RUN /storage/install-node.sh", "")
}
// pip config
if pipConfigFileStr != "" {
lines = append(lines, pipConfigFileStr, "")
}
// -- Installing dependencies from uv.lock --
lines = append(lines, "# -- Installing dependencies from uv.lock --")
lines = append(lines,
copyFromProjectRoot("pyproject.toml", uvExportProjectDir+"/pyproject.toml"))
lines = append(lines,
copyFromProjectRoot("uv.lock", uvExportProjectDir+"/uv.lock"))
// Copy workspace member pyproject.toml files into the export dir
// so that `uv export --package` can resolve workspace dependencies.
for _, pkg := range plan.InstallOrder {
if pkg.Root == plan.ProjectRoot {
continue
}
rel, err := filepath.Rel(plan.ProjectRoot, pkg.PyprojectPath)
if err != nil {
continue
}
relPosix := filepath.ToSlash(rel)
lines = append(lines,
copyFromProjectRoot(relPosix, uvExportProjectDir+"/"+relPosix))
}
lines = append(lines, fmt.Sprintf("WORKDIR %s", uvExportProjectDir))
quotedName := shellQuote(plan.Target.Name)
lines = append(lines, fmt.Sprintf("RUN uv export --package %s --frozen --no-hashes --no-emit-project --no-emit-workspace -o uv_requirements.txt", quotedName))
lines = append(lines, fmt.Sprintf("RUN %s -r uv_requirements.txt", globalReqsPipInstall))
lines = append(lines, "RUN rm -rf /tmp/uv_export")
lines = append(lines, "# -- End of uv.lock dependencies install --", "")
// Add workspace packages in install order
for _, pkg := range plan.InstallOrder {
rel, err := filepath.Rel(plan.ProjectRoot, pkg.Root)
if err != nil {
rel = "."
}
packageLabel := filepath.ToSlash(rel)
if packageLabel == "." {
packageLabel = "."
}
lines = append(lines, fmt.Sprintf("# -- Adding workspace package %s --", packageLabel))
copyItems, err := uvLockPackageCopyItems(pkg, plan)
if err != nil {
return "", nil, err
}
for _, item := range copyItems {
lines = append(lines, copyFromProjectRoot(item[0], item[1]))
}
lines = append(lines, fmt.Sprintf("WORKDIR %s", plan.ContainerRoots[pkg.Root]))
lines = append(lines, fmt.Sprintf("RUN %s --no-deps -e .", globalReqsPipInstall))
lines = append(lines, fmt.Sprintf("# -- End of workspace package %s --", packageLabel), "")
}
// env vars
if len(envVars) > 0 {
lines = append(lines, envVars...)
lines = append(lines, "")
}
// JS install
if (config["ui"] != nil || nv != "") && plan.WorkingDir != "" {
nodeVer := nv
if nodeVer == "" {
nodeVer = DefaultNodeVersion
}
lines = append(lines,
"# -- Installing JS dependencies --",
fmt.Sprintf("ENV NODE_VERSION=%s", nodeVer),
fmt.Sprintf("WORKDIR %s", plan.WorkingDir),
fmt.Sprintf("RUN %s && tsx /api/langgraph_api/js/build.mts",
GetNodePMInstallCmd(plan.TargetRoot)),
"# -- End of JS dependencies install --",
"",
)
}
// pip cleanup
lines = append(lines,
GetPipCleanupLines(installCmd, buildToolsToUninstall, "uv"),
"",
)
// working dir
if plan.WorkingDir != "" {
lines = append(lines, fmt.Sprintf("WORKDIR %s", plan.WorkingDir))
}
return strings.Join(lines, "\n"), additionalContexts, nil
}
// ---------------------------------------------------------------------------
// Utility helpers
// ---------------------------------------------------------------------------
// isEqualOrChild returns true if child equals parent or is a subdirectory of parent.
func isEqualOrChild(child, parent string) bool {
if child == parent {
return true
}
rel, err := filepath.Rel(parent, child)
if err != nil {
return false
}
return !strings.HasPrefix(rel, "..")
}
// sortedPackageNames returns a sorted comma-separated list of package names,
// or "(none)" if the map is empty.
func sortedPackageNames(pkgsByName map[string]*UvLockPackage) string {
if len(pkgsByName) == 0 {
return "(none)"
}
names := make([]string, 0, len(pkgsByName))
for _, pkg := range pkgsByName {
names = append(names, pkg.Name)
}
sort.Strings(names)
return strings.Join(names, ", ")
}