1
0
Fork 0
mirror of https://github.com/imjasonh/terraform-playground synced 2026-07-07 23:35:16 +00:00
terraform-playground/pymage/internal/lock/lock.go
Cursor Agent 7e8e2261a7
lock: evaluate PEP 508 markers and support --extra/--package in uv.lock closure
Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
2026-06-11 01:30:45 +00:00

254 lines
8.2 KiB
Go

// Package lock parses fully-pinned, hashed requirements files (the output of
// `pip-compile` / `uv pip compile --generate-hashes`).
//
// We deliberately do not resolve dependencies ourselves; we consume a lock that
// already pins every distribution to an exact version with one or more sha256
// hashes. The hashes are what make a build reproducible and let us key the
// layer cache by wheel content.
package lock
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
)
// WheelRef is a pinned wheel distribution from a lockfile (e.g. uv.lock).
type WheelRef struct {
URL string
SHA256 string // hex digest, no "sha256:" prefix
Filename string // wheel file name, used for tag parsing
}
// SdistRef is a pinned source distribution, used to build a wheel when no
// pre-built wheel is available.
type SdistRef struct {
URL string
SHA256 string // hex digest, no "sha256:" prefix
Filename string
}
// Requirement is a single pinned distribution.
type Requirement struct {
// Name is the project name as written (normalize with NormalizeName for
// comparisons).
Name string
// Version is the exact pinned version (from `==`).
Version string
// Hashes are the allowed sha256 hex digests (without the "sha256:" prefix).
Hashes []string
// Wheels lists known wheel artifacts for this pin (populated from uv.lock).
Wheels []WheelRef
// Sdist, when set, is the source distribution to build a wheel from if no
// compatible wheel is available.
Sdist *SdistRef
}
var (
// pinRE matches "name[extras]==version". Extras are accepted (and ignored
// for matching, since a resolved lock pins their dependencies separately);
// environment markers after ";" are also ignored.
pinRE = regexp.MustCompile(`^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*==\s*([^\s;]+)`)
hashRE = regexp.MustCompile(`--hash=sha256:([0-9a-fA-F]{64})`)
normRE = regexp.MustCompile(`[-_.]+`)
// dependencyOptions reference further requirements rather than configuring
// the resolver. Silently skipping them could omit dependencies, so we
// reject them: a lock fed to pymage must be fully inlined and pinned.
dependencyOptions = []string{"-e", "--editable", "-r", "--requirement", "-c", "--constraint"}
)
// NormalizeName implements PEP 503 name normalization.
func NormalizeName(name string) string {
return strings.ToLower(normRE.ReplaceAllString(name, "-"))
}
// ParseFile reads and parses a requirements file from disk.
func ParseFile(path string) ([]Requirement, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
return Parse(f)
}
// ParseAny reads a lock file, choosing the parser from the filename:
// uv.lock is parsed as TOML; everything else is treated as requirements.txt.
func ParseAny(path string) ([]Requirement, error) {
if strings.HasSuffix(strings.ToLower(filepath.Base(path)), ".lock") &&
!strings.HasSuffix(strings.ToLower(path), "requirements.lock") {
// uv.lock / poetry.lock-style names ending in .lock (not requirements.lock
// which is pip-compile output in requirements format).
if strings.EqualFold(filepath.Base(path), "uv.lock") {
return ParseUVLockFile(path)
}
}
// requirements.lock and requirements.txt both use the pip format.
return ParseFile(path)
}
// Lock is a parsed lock file (parsed once) that can be resolved per target.
type Lock struct {
isUV bool
uv *uvLockFile // uv.lock
reqs []Requirement // requirements format
}
// Options selects the resolution for a uv.lock: a workspace package, extras to
// enable, and the target environment for marker evaluation. They are ignored
// for requirements-format locks (which are already flat and pinned).
type Options struct {
Package string
Extras []string
OS, Arch string
PyMajor, PyMinor int
}
// Load reads and parses a lock file once. uv.lock is parsed as TOML; everything
// else is treated as a requirements file.
func Load(path string) (*Lock, error) {
if strings.EqualFold(filepath.Base(path), "uv.lock") {
lf, err := parseUVLock(path)
if err != nil {
return nil, err
}
return &Lock{isUV: true, uv: lf}, nil
}
reqs, err := ParseFile(path)
if err != nil {
return nil, err
}
return &Lock{reqs: reqs}, nil
}
// Resolve returns the pinned requirements for the given target. For uv.lock
// this is the runtime closure (extras/package/markers applied); for a
// requirements file it is the flat pinned list.
func (l *Lock) Resolve(o Options) ([]Requirement, error) {
if !l.isUV {
return append([]Requirement(nil), l.reqs...), nil
}
var env MarkerEnv
if o.PyMajor != 0 {
env = NewMarkerEnv(o.OS, o.Arch, o.PyMajor, o.PyMinor)
}
return resolveUV(l.uv, UVOptions{Package: o.Package, Extras: o.Extras, Env: env})
}
// DiscoverLock returns the path to a lock file in dir, preferring uv.lock.
func DiscoverLock(dir string) (string, error) {
for _, name := range []string{"uv.lock", "requirements.lock", "requirements.txt"} {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return "", fmt.Errorf("no lock file found in %q (expected uv.lock, requirements.lock, or requirements.txt)", dir)
}
// Parse reads pinned requirements from r. Logical lines may span multiple
// physical lines using a trailing backslash, which is how `--hash` entries are
// typically formatted.
func Parse(r io.Reader) ([]Requirement, error) {
var reqs []Requirement
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var logical strings.Builder
flush := func() error {
line := logical.String()
logical.Reset()
stripped := stripComment(line)
if strings.TrimSpace(stripped) == "" {
return nil
}
// Configuration options (e.g. --index-url) aren't pins and are skipped,
// but options that pull in other requirements must not be silently
// ignored.
if isOption(stripped) {
return optionError(stripped)
}
m := pinRE.FindStringSubmatch(stripped)
if m == nil {
// We only accept fully-pinned "name==version" requirements. Refuse
// (rather than silently drop) anything else — unpinned constraints,
// URL/VCS direct references, `-e` editables — so a lock can never
// quietly omit a dependency from the image.
return fmt.Errorf("lock: unsupported requirement line %q (expected name==version)", strings.TrimSpace(stripped))
}
req := Requirement{Name: m[1], Version: m[2]}
for _, h := range hashRE.FindAllStringSubmatch(stripped, -1) {
req.Hashes = append(req.Hashes, strings.ToLower(h[1]))
}
reqs = append(reqs, req)
return nil
}
for sc.Scan() {
line := sc.Text()
trimmed := strings.TrimRight(line, " \t")
if strings.HasSuffix(trimmed, "\\") {
logical.WriteString(strings.TrimSuffix(trimmed, "\\"))
logical.WriteString(" ")
continue
}
logical.WriteString(line)
if err := flush(); err != nil {
return nil, err
}
}
if err := sc.Err(); err != nil {
return nil, err
}
if logical.Len() > 0 {
if err := flush(); err != nil {
return nil, err
}
}
return reqs, nil
}
func stripComment(s string) string {
// A '#' starts a comment. Requirements files don't support escaping '#',
// and no valid pinned/hashed line contains one, so we strip from the first
// '#' and drop any trailing whitespace it leaves behind.
if i := strings.IndexByte(s, '#'); i >= 0 {
s = s[:i]
}
return strings.TrimRight(s, " \t")
}
func isOption(s string) bool {
t := strings.TrimSpace(s)
return strings.HasPrefix(t, "-")
}
// optionError returns an error for dependency-bearing options (which we cannot
// honor from a local wheelhouse), and nil for benign configuration options.
func optionError(s string) error {
t := strings.TrimSpace(s)
for _, opt := range dependencyOptions {
if t == opt || strings.HasPrefix(t, opt+" ") || strings.HasPrefix(t, opt+"=") {
return fmt.Errorf("lock: unsupported option %q; referenced requirements must be inlined and pinned", t)
}
}
return nil
}
// Validate ensures every requirement is pinned and (optionally) hashed.
func Validate(reqs []Requirement, requireHashes bool) error {
for _, r := range reqs {
if r.Version == "" {
return fmt.Errorf("lock: requirement %q is not pinned with ==", r.Name)
}
if requireHashes && len(r.Hashes) == 0 && r.Sdist == nil {
return fmt.Errorf("lock: requirement %q has no --hash entries", r.Name)
}
}
return nil
}