diff --git a/.gitignore b/.gitignore index 5eadf1e..8ff07c6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ box/uvbox.toml box/wheels +box/git_source.txt boxer/boxes boxer/boxer diff --git a/README.md b/README.md index 8c5f359..ef9a8fa 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ ## Features -- **Package from PyPI or Wheels** — Install your application from package indexes or choose to bundle local wheel files +- **Package from PyPI, Wheels, or Git** — Install your application from package indexes, bundle local wheel files, or fetch from a git repository - **True Cross-Compilation** — Build binaries for Linux, macOS, and Windows (AMD64/ARM64) from any platform in seconds - **Auto-Updates** — Built-in version checking and self-update/fallback capabilities for your binaries - **Dependency Freezing** — Use constraints files to ensure reproducible installations @@ -85,6 +85,12 @@ pip install uvbox ### Basic Usage +uvbox supports three source types, each via its own subcommand: + +- **`uvbox pypi`** — install the package from a package index (PyPI by default) +- **`uvbox wheel`** — bundle one or more local wheel files into the binary +- **`uvbox git`** — fetch the package from a git repository at runtime + Create a simple configuration and build: ```bash @@ -116,6 +122,49 @@ Package local wheel files instead of installing from PyPI: uvbox wheel --config uvbox.toml ./my-app.whl ``` +### Build from a Git Repository + +Package an application from a git repository that isn't published to PyPI. +The examples below use [`VaasuDevanS/cowsay-python`](https://github.com/VaasuDevanS/cowsay-python) +so they're copy-paste runnable — pair them with [`examples/git/simple-app.toml`](./examples/git/simple-app.toml): + +```bash +# Default branch +uvbox git git+https://github.com/VaasuDevanS/cowsay-python --config examples/git/simple-app.toml + +# Specific tag +uvbox git git+https://github.com/VaasuDevanS/cowsay-python@v6.1 + +# Specific branch +uvbox git git+https://github.com/VaasuDevanS/cowsay-python@main + +# Specific commit +uvbox git git+https://github.com/VaasuDevanS/cowsay-python@abc123 + +# SSH (uses your local git credentials) +uvbox git git+ssh://git@github.com/org/private-repo +``` + +The git spec is passed through to `uv tool install --from` verbatim at runtime +on the end-user's machine. `uvbox` itself never clones the repository at build +time — the clone happens on first run of the generated binary. This means you +can build binaries for a private repo without providing credentials to the +build machine; the end user's local git/ssh setup handles authentication. + +**Behavior of `[package.version]` for git builds:** +- `static` and `dynamic` are ignored for install resolution — the git ref in + the spec is the source of truth. `uvToolInstallGit` logs a warning and + drops any supplied version. Leave both unset when tracking a moving branch. +- With `auto-update = true` and neither `static` nor `dynamic` set, the + runtime version check has no target to compare against and falls through + to re-running `uv tool install --from --upgrade` on every + invocation, giving you the "fresh dependencies every run" behavior + equivalent to `pycrucible`'s `delete_after_run = true`. +- Setting `static = "x.y.z"` or a `dynamic` URL that happens to resolve to + the currently installed version will **disable** the always-update + behavior — the outer version compare matches and skips the update path + before `uvToolInstallGit` ever runs. + ## Configuration ### Using pyproject.toml @@ -200,8 +249,12 @@ environment = [ #### `[package]` Core package configuration. -- **`name`** (required) — Package name to install from PyPI -- **`script`** (required) — Entry point script to run (from `[project.scripts]` in your package) +- **`name`** (required) — Distribution name the package registers (what + `uv tool list` reports after install). Used for all source types + (`pypi`, `wheel`, `git`) — for `git` it must match the name declared in + the repo's `pyproject.toml`, not the repo/org slug. +- **`script`** (required) — Entry point to run. Must match an entry from + the package's `[project.scripts]`. Applies to all source types. #### `[package.version]` Version management and updates. @@ -372,10 +425,11 @@ auto-update = true See the [`examples/`](./examples) directory for complete working examples: -- [`simple-app.toml`](./examples/pypi/simple-app.toml) — Minimal PyPI package -- [`custom-registry.toml`](./examples/pypi/custom-registry.toml) — Custom registry and mirrors -- [`custom-certs.toml`](./examples/pypi/custom-certs.toml) — Corporate CA bundle -- [`optional-dependency.toml`](./examples/pypi/optional-dependency.toml) - Install a package with an optional dependency +- [`git/simple-app.toml`](./examples/git/simple-app.toml) — Minimal Python package from a Git repository +- [`pypi/simple-app.toml`](./examples/pypi/simple-app.toml) — Minimal Python package from PyPI +- [`pypi/custom-registry.toml`](./examples/pypi/custom-registry.toml) — Custom registry and mirrors +- [`pypi/custom-certs.toml`](./examples/pypi/custom-certs.toml) — Corporate CA bundle +- [`pypi/optional-dependency.toml`](./examples/pypi/optional-dependency.toml) — Install a package with an optional dependency ## Requirements @@ -387,6 +441,9 @@ See the [`examples/`](./examples) directory for complete working examples: ### Runtime (Generated Binaries) - **libc** (standard C library, required by Python itself) +- **git** (only for binaries built with `uvbox git` — `uv` shells out to the + system `git` on first run to clone the repository. For `ssh://` specs, the + end user's local SSH keys/agent are used for authentication.) ## License diff --git a/box/box_package.go b/box/box_package.go index 8b2a333..24b5946 100644 --- a/box/box_package.go +++ b/box/box_package.go @@ -156,11 +156,47 @@ func (b *Box) InstalledPackagePath() (string, error) { return installedPackage.Path, nil } +// installMethod identifies which install backend uvToolInstall should use. +type installMethod int + +const ( + installMethodPypi installMethod = iota + installMethodWheels + installMethodGit +) + +// selectInstallMethod is the pure-function core of the uvToolInstall dispatch. +// It returns an error when the build-time configuration is inconsistent — +// specifically when both GIT_SOURCE and INSTALL_WHEELS are set, which would +// otherwise silently favor one source and discard the other. +func selectInstallMethod(gitSource, installWheels string) (installMethod, error) { + gitSet := gitSource != "" + wheelsSet := installWheels == "yes" + if gitSet && wheelsSet { + return 0, fmt.Errorf("invalid binary: both GIT_SOURCE and INSTALL_WHEELS are set; this indicates a build-time bug, please rebuild") + } + switch { + case gitSet: + return installMethodGit, nil + case wheelsSet: + return installMethodWheels, nil + default: + return installMethodPypi, nil + } +} + func (b *Box) uvToolInstall(packageVersion, constraintsFile string) error { - if INSTALL_WHEELS == "no" { - return b.uvToolInstallPypi(packageVersion, constraintsFile) - } else { + method, err := selectInstallMethod(GIT_SOURCE, INSTALL_WHEELS) + if err != nil { + return err + } + switch method { + case installMethodGit: + return b.uvToolInstallGit(packageVersion, constraintsFile) + case installMethodWheels: return b.uvToolInstallWheels(constraintsFile) + default: + return b.uvToolInstallPypi(packageVersion, constraintsFile) } } @@ -210,6 +246,7 @@ func (b *Box) uvToolInstallPypi(packageVersion, constraintsFile string) error { "name": b.PackageName, "version": packageVersion, "constraintsFile": constraintsFile, + "method": "pypi", } logger.Debug("Installing package", logger.ArgsFromMap(debugArgsMap)) diff --git a/box/box_package_git.go b/box/box_package_git.go new file mode 100644 index 0000000..295acd2 --- /dev/null +++ b/box/box_package_git.go @@ -0,0 +1,110 @@ +package main + +import ( + _ "embed" + "fmt" + "os" + "os/exec" + "strings" +) + +// gitSourceContent is the raw content of git_source.txt, embedded at build +// time. For `uvbox git ` builds, boxer writes the git spec into this +// file before invoking `go build`. For pypi/wheel builds, the file is +// created empty by box/generate.go (it is gitignored — not a committed +// file) so the //go:embed directive always has a target. +// +// We use a file-embed instead of an ldflag (-X main.GIT_SOURCE=...) because +// the Go toolchain splits GOFLAGS on whitespace, so any ldflag value +// containing spaces (or `-X main.FOO=bar` that uses an `=` plus another +// flag) breaks parsing. The boxer build path routes ldflags through GOFLAGS +// for Windows compatibility (see issue AmadeusITGroup/uvbox#7 and the +// comment above buildGoBuildLdflags in boxer/git.go). +// +//go:embed git_source.txt +var gitSourceContent string + +// GIT_SOURCE is the embedded git source string (e.g., "git+https://github.com/org/repo@main") +// for binaries produced by `uvbox git `. It is empty for pypi/wheel +// builds, in which case the runtime install path skips the git dispatch +// entirely. strings.TrimSpace tolerates a trailing newline if the writer +// ever grows one. +var GIT_SOURCE = strings.TrimSpace(gitSourceContent) + +// buildUvToolInstallFromArgs constructs the command-line arguments for +// `uv tool install --from --upgrade`, optionally +// appending `--with-requirements `. Pulled out as a +// standalone helper so it can be tested without invoking `uv`. +// +// `--upgrade` is always included because uv has no cached version identity +// for a git install — the ref in the spec is opaque to uv's upgrade check, +// so forcing the re-install path is the only way to pick up new commits +// for a moving ref like `@main`. Whether this function is *called* at all +// is still gated by the outer version-check path in box/main.go — see the +// README section "Behavior of [package.version] for git builds". +// +// Constraints apply to the transitive dependency resolution — the primary +// package is pinned by the git ref itself. +func buildUvToolInstallFromArgs(uvPath, gitSource, packageName, constraintsFile string) []string { + args := []string{ + uvPath, + "--quiet", + "tool", + "install", + "--from", + gitSource, + packageName, + "--upgrade", + } + if constraintsFile != "" { + args = append(args, "--with-requirements", constraintsFile) + } + return args +} + +// uvToolInstallGit installs the package from the embedded GIT_SOURCE via +// `uv tool install --from --upgrade`. +// +// packageVersion is accepted for signature parity with uvToolInstallPypi, +// but is intentionally not used to construct the install spec: the git ref +// in GIT_SOURCE is the source of truth. If the caller supplies a non-empty +// packageVersion (i.e., the user set [package.version].static on a git +// build), a user-visible warning is emitted so the mismatch is not silent. +func (b *Box) uvToolInstallGit(packageVersion, constraintsFile string) error { + if packageVersion != "" { + logger.Warn("Ignoring [package.version] for git source; the git ref in GIT_SOURCE is the source of truth", + logger.Args("ignoredVersion", packageVersion, "gitSource", GIT_SOURCE)) + } + + logger.Debug("Installing package", + logger.Args("name", b.PackageName, "source", GIT_SOURCE, "constraintsFile", constraintsFile, "method", "git")) + + uv, err := b.InstalledUvExecutablePath() + if err != nil { + return fmt.Errorf("could not find uv executable: %w", err) + } + + commandArgs := buildUvToolInstallFromArgs(uv, GIT_SOURCE, b.PackageName, constraintsFile) + + env, err := b.commandsEnvironment() + if err != nil { + return fmt.Errorf("could not get uv environment variables: %w", err) + } + + cmd := exec.Command(commandArgs[0], commandArgs[1:]...) + cmd.Env = env + cmd.Stderr = os.Stderr + // Enable Stdout if debug is enabled + if debugEnabled() || traceEnabled() { + cmd.Stdout = os.Stdout + } + logger.Trace("Running", logger.Args("command", commandArgs, "env", env)) + + err = cmd.Run() + if err != nil { + return fmt.Errorf("failed to run command %v: %w", commandArgs, err) + } + + logger.Debug("Installed", logger.Args("package", b.PackageName)) + return nil +} diff --git a/box/box_package_git_test.go b/box/box_package_git_test.go new file mode 100644 index 0000000..49e3f13 --- /dev/null +++ b/box/box_package_git_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestBuildUvToolInstallFromArgs_Minimal(t *testing.T) { + got := buildUvToolInstallFromArgs("/path/to/uv", "git+https://github.com/org/repo", "mypkg", "") + want := []string{ + "/path/to/uv", + "--quiet", + "tool", + "install", + "--from", + "git+https://github.com/org/repo", + "mypkg", + "--upgrade", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildUvToolInstallFromArgs minimal = %v, want %v", got, want) + } +} + +func TestBuildUvToolInstallFromArgs_WithRef(t *testing.T) { + got := buildUvToolInstallFromArgs("uv", "git+https://github.com/org/repo@v1.0.0", "mypkg", "") + want := []string{ + "uv", + "--quiet", + "tool", + "install", + "--from", + "git+https://github.com/org/repo@v1.0.0", + "mypkg", + "--upgrade", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildUvToolInstallFromArgs with ref = %v, want %v", got, want) + } +} + +func TestBuildUvToolInstallFromArgs_WithConstraints(t *testing.T) { + got := buildUvToolInstallFromArgs("uv", "git+https://github.com/org/repo", "mypkg", "/tmp/constraints.txt") + want := []string{ + "uv", + "--quiet", + "tool", + "install", + "--from", + "git+https://github.com/org/repo", + "mypkg", + "--upgrade", + "--with-requirements", + "/tmp/constraints.txt", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildUvToolInstallFromArgs with constraints = %v, want %v", got, want) + } +} diff --git a/box/box_package_test.go b/box/box_package_test.go new file mode 100644 index 0000000..f9e0cad --- /dev/null +++ b/box/box_package_test.go @@ -0,0 +1,38 @@ +package main + +import "testing" + +func TestSelectInstallMethod(t *testing.T) { + cases := []struct { + name string + gitSource string + installWheels string + want installMethod + wantErr bool + }{ + {"pypi_default", "", "no", installMethodPypi, false}, + {"pypi_empty_install_wheels", "", "", installMethodPypi, false}, + {"wheels", "", "yes", installMethodWheels, false}, + {"git_no_wheels", "git+https://github.com/org/repo", "no", installMethodGit, false}, + {"git_empty_install_wheels", "git+https://github.com/org/repo@main", "", installMethodGit, false}, + {"conflict_git_and_wheels", "git+https://github.com/org/repo", "yes", 0, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := selectInstallMethod(tc.gitSource, tc.installWheels) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for git+wheels conflict, got method=%v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("selectInstallMethod(%q, %q) = %v, want %v", tc.gitSource, tc.installWheels, got, tc.want) + } + }) + } +} diff --git a/box/config.go b/box/config.go index 371130c..2965c6e 100644 --- a/box/config.go +++ b/box/config.go @@ -70,6 +70,9 @@ func (c Configuration) PanicIfInvalid() { func (c Configuration) ComputeIdentifier() string { hasher := crypto.SHA1.New() textToHash := fmt.Sprintf("%s%s%s%t", c.Package.Name, c.Package.Script, c.Package.Version.Static, c.AutoUpdateEnabled()) + if GIT_SOURCE != "" { + textToHash += GIT_SOURCE + } _, err := io.WriteString(hasher, textToHash) if err != nil { logger.Fatal("Failed to hash script value", logger.Args("error", err)) diff --git a/box/config_identifier_test.go b/box/config_identifier_test.go new file mode 100644 index 0000000..7a25cde --- /dev/null +++ b/box/config_identifier_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "testing" +) + +// makeTestConfig returns a Configuration representative of a typical pypi build. +func makeTestConfig() Configuration { + return Configuration{ + Package: PackageConfiguration{ + Name: "my-package", + Script: "my-script", + Version: PackageVersionConfiguration{ + AutoUpdate: true, + Static: "1.0.0", + }, + }, + } +} + +// TestComputeIdentifier_StableForPypi is the regression guard for +// "additive only, don't affect existing features". The expected value +// was captured from the pre-git-support version of ComputeIdentifier +// and must not change when GIT_SOURCE is empty. If this hash changes, +// existing deployed pypi/wheel binaries will fail to find their +// installed package on next run — treat any change here as breaking. +// +// The hash is SHA1 of: +// +// c.Package.Name + c.Package.Script + c.Package.Version.Static + +// fmt.Sprintf("%t", c.AutoUpdateEnabled()) +// +// For makeTestConfig() above: "my-package" + "my-script" + "1.0.0" + "true". +// To regenerate after an intentional breaking change, run: +// +// cfg := makeTestConfig() +// GIT_SOURCE = "" +// fmt.Println(cfg.ComputeIdentifier()) +func TestComputeIdentifier_StableForPypi(t *testing.T) { + // Ensure GIT_SOURCE is empty for this test (it's the default, but be explicit). + origGitSource := GIT_SOURCE + GIT_SOURCE = "" + t.Cleanup(func() { GIT_SOURCE = origGitSource }) + + cfg := makeTestConfig() + got := cfg.ComputeIdentifier() + want := "my-package-30bb8d607d1417531f6df2a733f8ad02439c2b3a" + if got != want { + t.Fatalf("ComputeIdentifier() = %q, want %q (regression: existing pypi/wheel binaries must hash identically)", got, want) + } +} + +func TestComputeIdentifier_DiffersByGitSource(t *testing.T) { + cfg := makeTestConfig() + + origGitSource := GIT_SOURCE + t.Cleanup(func() { GIT_SOURCE = origGitSource }) + + GIT_SOURCE = "" + pypiID := cfg.ComputeIdentifier() + + GIT_SOURCE = "git+https://github.com/org/repo" + gitID := cfg.ComputeIdentifier() + + if pypiID == gitID { + t.Fatalf("expected git build to produce a different identifier than pypi build, both got %q", pypiID) + } +} + +func TestComputeIdentifier_DiffersByGitRef(t *testing.T) { + cfg := makeTestConfig() + + origGitSource := GIT_SOURCE + t.Cleanup(func() { GIT_SOURCE = origGitSource }) + + GIT_SOURCE = "git+https://github.com/org/repo@main" + mainID := cfg.ComputeIdentifier() + + GIT_SOURCE = "git+https://github.com/org/repo@v1.0.0" + tagID := cfg.ComputeIdentifier() + + if mainID == tagID { + t.Fatalf("expected different git refs to produce different identifiers, both got %q", mainID) + } +} diff --git a/box/generate.go b/box/generate.go index d4ca121..f21f559 100644 --- a/box/generate.go +++ b/box/generate.go @@ -12,6 +12,7 @@ var CONFIGURATION_FILENAME = "uvbox.toml" var CERTIFICATES_BUNDLE_FILENAME = "ca-bundle.crt" var WHEELS_FOLDER = "wheels" var WHEELS_PLACEHOLDER = filepath.Join(WHEELS_FOLDER, "placeholder") +var GIT_SOURCE_FILENAME = "git_source.txt" func deleteIfExists(filename string) { if _, err := os.Stat(filename); err != nil && os.IsNotExist(err) { @@ -50,4 +51,8 @@ func main() { // Generate wheels folder placeholder generateEmptyFileIfMissing(WHEELS_PLACEHOLDER) + + // Generate empty git_source.txt placeholder (populated at uvbox-build + // time by boxer's writeGitSourceFile when `uvbox git ` is used). + generateEmptyFileIfMissing(GIT_SOURCE_FILENAME) } diff --git a/boxer/git.go b/boxer/git.go new file mode 100644 index 0000000..9dca2bf --- /dev/null +++ b/boxer/git.go @@ -0,0 +1,101 @@ +package main + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strings" +) + +// buildGoBuildLdflags constructs the ldflags string passed to `go build` +// via the `GOFLAGS=-ldflags=...` environment variable. Pure function: +// no side effects, no package-level state reads, so it can be unit-tested +// without invoking go build. +// +// Behavior: +// - Always includes "-s -w" to strip debug info. +// - If wheels are embedded, adds "-X main.INSTALL_WHEELS=yes" (unchanged +// from the pre-git-support behavior — regression test locks this in). +// +// Note on git source: unlike wheels, the git source is NOT injected via +// ldflags. The Go toolchain splits GOFLAGS on whitespace, so any ldflag +// string containing `-X main.FOO=bar` is broken across flag boundaries +// and the toolchain rejects `-X` as an unknown top-level flag. Instead, +// the git source is written to box/git_source.txt and embedded via +// //go:embed — see writeGitSourceFile below and box/box_package_git.go. +func buildGoBuildLdflags(wheelsToEmbed []string) string { + ldflags := "-s -w" + if len(wheelsToEmbed) > 0 { + ldflags += " -X main.INSTALL_WHEELS=yes" + } + return ldflags +} + +// writeGitSourceFile writes the provided git source string to +// /git_source.txt, which is embedded into the compiled +// binary via //go:embed in box/box_package_git.go. The file is always +// written (even with an empty string) to satisfy the go:embed directive, +// which requires the target file to exist at build time. An empty file +// means "not a git build"; a non-empty file means "git build, this is +// the spec to pass to uv tool install --from". +func writeGitSourceFile(boxRepository, gitSource string) error { + target := filepath.Join(boxRepository, "git_source.txt") + if err := os.WriteFile(target, []byte(gitSource), 0644); err != nil { + return fmt.Errorf("failed to write git source file to %s: %w", target, err) + } + return nil +} + +// validateGitSource is called from preRun when a GitSource CLI argument +// was provided. It enforces the uvbox-side format constraints so that +// obvious typos fail at build time rather than on every end-user machine: +// +// - must begin with "git+" +// - the URL after the "git+" prefix must parse +// - scheme must be one of http, https, ssh, file +// - non-file schemes must have a host +// +// Semantic ref resolution (branch, tag, commit existence) is still +// delegated to uv on first run of the generated binary. +func validateGitSource(gitSource string) error { + if gitSource == "" { + return fmt.Errorf("git source must not be empty") + } + if !strings.HasPrefix(gitSource, "git+") { + return fmt.Errorf("git source must start with 'git+' (e.g. git+https://github.com/org/repo), got %q", gitSource) + } + + raw := strings.TrimPrefix(gitSource, "git+") + if raw == "" { + return fmt.Errorf("git source is missing a URL after 'git+' prefix, got %q", gitSource) + } + + // Strip an optional trailing "@ref" before URL parsing, but only when + // the `@` is not part of a userinfo segment like "ssh://git@host/...". + // Any `@` appearing after the first `/` of the path component is an + // "@ref" suffix; userinfo `@` always precedes the host segment. + parseTarget := raw + if slash := strings.Index(raw, "/"); slash != -1 { + if at := strings.LastIndex(raw, "@"); at > slash { + parseTarget = raw[:at] + } + } + + u, err := url.Parse(parseTarget) + if err != nil { + return fmt.Errorf("git source %q is not a valid URL: %w", gitSource, err) + } + + switch u.Scheme { + case "http", "https", "ssh", "file": + default: + return fmt.Errorf("git source scheme %q is not supported; use http(s), ssh, or file (got %q)", u.Scheme, gitSource) + } + + if u.Scheme != "file" && u.Host == "" { + return fmt.Errorf("git source %q is missing a host", gitSource) + } + + return nil +} diff --git a/boxer/git_test.go b/boxer/git_test.go new file mode 100644 index 0000000..844b38a --- /dev/null +++ b/boxer/git_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestBuildGoBuildLdflags_Pypi is a regression guard: the ldflag string +// produced for a plain pypi build must match what goBuild used to produce +// before git support was added. Git support does NOT inject ldflags +// (see boxer/git.go for why) so adding it must not change this output. +func TestBuildGoBuildLdflags_Pypi(t *testing.T) { + got := buildGoBuildLdflags(nil) + want := "-s -w" + if got != want { + t.Fatalf("buildGoBuildLdflags pypi = %q, want %q", got, want) + } +} + +// TestBuildGoBuildLdflags_Wheel is a regression guard for the wheel path. +func TestBuildGoBuildLdflags_Wheel(t *testing.T) { + got := buildGoBuildLdflags([]string{"some-wheel.whl"}) + want := "-s -w -X main.INSTALL_WHEELS=yes" + if got != want { + t.Fatalf("buildGoBuildLdflags wheel = %q, want %q", got, want) + } +} + +func TestWriteGitSourceFile_Empty(t *testing.T) { + dir := t.TempDir() + if err := writeGitSourceFile(dir, ""); err != nil { + t.Fatalf("writeGitSourceFile empty failed: %v", err) + } + + got, err := os.ReadFile(filepath.Join(dir, "git_source.txt")) + if err != nil { + t.Fatalf("failed to read written file: %v", err) + } + if string(got) != "" { + t.Fatalf("expected empty file, got %q", string(got)) + } +} + +func TestWriteGitSourceFile_WithSpec(t *testing.T) { + dir := t.TempDir() + spec := "git+https://github.com/org/repo@main" + + if err := writeGitSourceFile(dir, spec); err != nil { + t.Fatalf("writeGitSourceFile failed: %v", err) + } + + got, err := os.ReadFile(filepath.Join(dir, "git_source.txt")) + if err != nil { + t.Fatalf("failed to read written file: %v", err) + } + if string(got) != spec { + t.Fatalf("expected %q, got %q", spec, string(got)) + } +} + +func TestValidateGitSource_Empty(t *testing.T) { + if err := validateGitSource(""); err == nil { + t.Fatal("expected error for empty git source, got nil") + } +} + +func TestValidateGitSource_MissingGitPrefix(t *testing.T) { + cases := []string{ + "https://github.com/org/repo", + "http://github.com/org/repo", + "github.com/org/repo", + "ssh://git@github.com/org/repo", + "file:///tmp/repo", + } + for _, s := range cases { + t.Run(s, func(t *testing.T) { + if err := validateGitSource(s); err == nil { + t.Fatalf("expected error for %q, got nil", s) + } + }) + } +} + +func TestValidateGitSource_AcceptsValidSpecs(t *testing.T) { + cases := []string{ + "git+https://github.com/org/repo", + "git+https://github.com/org/repo@main", + "git+https://github.com/org/repo@v1.0.0", + "git+https://github.com/org/repo@abc123def456", + "git+https://github.com/org/repo@feature/new-thing", + "git+ssh://git@github.com/org/repo", + "git+ssh://git@github.com/org/repo@main", + "git+file:///tmp/repo", + } + for _, s := range cases { + t.Run(s, func(t *testing.T) { + if err := validateGitSource(s); err != nil { + t.Fatalf("expected %q to be valid, got error: %v", s, err) + } + }) + } +} + +// TestValidateGitSource_RejectsMalformed covers the deeper URL-parsing +// checks: typos in scheme, missing host, prefix-only specs. +func TestValidateGitSource_RejectsMalformed(t *testing.T) { + cases := []string{ + "git+", // prefix only + "git+htps://github.com/org/repo", // scheme typo + "git+ftp://github.com/org/repo", // unsupported scheme + "git+https:///org/repo", // no host + "git+https://", // no host, no path + } + for _, s := range cases { + t.Run(s, func(t *testing.T) { + if err := validateGitSource(s); err == nil { + t.Fatalf("expected %q to be rejected, got nil", s) + } + }) + } +} + +// TestValidateGitSourceFlag_EmptyIsNoop guards the contract that pypi/wheel +// builds (which leave GitSource empty) are never affected by the git +// validation path. If this ever regresses, every pypi/wheel build breaks. +func TestValidateGitSourceFlag_EmptyIsNoop(t *testing.T) { + orig := GitSource + t.Cleanup(func() { GitSource = orig }) + GitSource = "" + // Must not call logger.Fatal. + validateGitSourceFlag() +} diff --git a/boxer/main.go b/boxer/main.go index f047a65..e0696dc 100644 --- a/boxer/main.go +++ b/boxer/main.go @@ -31,6 +31,7 @@ var Config string var Output string var Nfpm string var ReleaseVersion string +var GitSource string var Darwin bool var Linux bool @@ -104,6 +105,35 @@ func main() { wheelCmd.Flags().BoolVarP(&Amd, "amd", "", false, "Build for AMD64") wheelCmd.Flags().BoolVarP(&Arm, "arm", "", false, "build for ARM64") + // UVBOX GIT + var gitCmd = &cobra.Command{ + Use: "git ", + Short: "Use a git repository as package source to generate a standalone executable", + Long: "Use a git repository as package source to generate a standalone executable.\n\n" + + "The git spec is passed through to `uv tool install --from` verbatim.\n" + + "Examples:\n" + + " uvbox git git+https://github.com/org/repo\n" + + " uvbox git git+https://github.com/org/repo@main\n" + + " uvbox git git+https://github.com/org/repo@v1.0.0", + Args: cobra.ExactArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + GitSource = args[0] + preRun() + }, + Run: func(cmd *cobra.Command, args []string) { + if err := run(); err != nil { + logger.Fatal("failed to run git command", logger.Args("error", err)) + } + }, + } + gitCmd.Flags().StringVarP(&Config, "config", "c", "", "Configuration file") + gitCmd.Flags().StringVarP(&Output, "output", "o", "dist", "Output directory") + gitCmd.Flags().BoolVarP(&Darwin, "darwin", "d", false, "Build for darwin") + gitCmd.Flags().BoolVarP(&Linux, "linux", "l", false, "Build for linux") + gitCmd.Flags().BoolVarP(&Windows, "windows", "w", false, "Build for windows") + gitCmd.Flags().BoolVarP(&Amd, "amd", "", false, "Build for AMD64") + gitCmd.Flags().BoolVarP(&Arm, "arm", "", false, "build for ARM64") + // UVBOX var rootCmd = &cobra.Command{ Use: "uvbox", @@ -112,6 +142,7 @@ func main() { } rootCmd.AddCommand(pypiCmd) rootCmd.AddCommand(wheelCmd) + rootCmd.AddCommand(gitCmd) rootCmd.PersistentFlags().StringVarP(&ReleaseVersion, "release-version", "", "0.0.0", "Specify a version for the binaries. Will be used for example for versioning linux packages.") rootCmd.PersistentFlags().BoolVarP(&NoBanner, "no-banner", "", false, "Do not display the banner") rootCmd.PersistentFlags().StringVarP(&Nfpm, "nfpm", "", "", "Generate linux packages with the given nfpm configuration file") @@ -127,6 +158,7 @@ func preRun() { validateGoAvailability() validateNfpmAvailability() validateWheelsToEmbed() + validateGitSourceFlag() } func validateOutputDirectoryFlag() { @@ -161,6 +193,20 @@ func validateWheelsToEmbed() { } } +func validateGitSourceFlag() { + if GitSource == "" { + return + } + if len(WheelsToEmbed) > 0 { + logger.Fatal("cannot combine a git source with embedded wheels; use either `uvbox git` or `uvbox wheel`, not both") + } + if err := validateGitSource(GitSource); err != nil { + logger.Fatal("invalid git source argument", + logger.Args("error", err, "providedValue", GitSource, + "hint", "expected form: git+https://host/org/repo[@ref] or git+ssh://git@host/org/repo[@ref]")) + } +} + type CompilationConfiguration struct { OS string ARCH string @@ -208,6 +254,11 @@ func insertFilesIntoBoxRepository(boxRepository string) error { } } + // Always write — see writeGitSourceFile. + if err := writeGitSourceFile(boxRepository, GitSource); err != nil { + return fmt.Errorf("failed to write git source file: %w", err) + } + return nil } @@ -355,11 +406,9 @@ func buildGoflagsEnv(ldflags string) (string, error) { func goBuild(repository, buildName, platform, arch string) error { var outbuf, errbuf strings.Builder - // Compilation flag - ldflags := "-s -w" - if len(WheelsToEmbed) > 0 { - ldflags += " -X main.INSTALL_WHEELS=yes" - } + // See buildGoBuildLdflags in boxer/git.go for why git source is not + // injected via ldflags. + ldflags := buildGoBuildLdflags(WheelsToEmbed) // Use $GOFLAGS instead of CLI args because Windows fails to escape ldflags // correctly when uvbox is ran through uv/uvx (?!). See issue #7. diff --git a/examples/git/simple-app.toml b/examples/git/simple-app.toml new file mode 100644 index 0000000..3a99dec --- /dev/null +++ b/examples/git/simple-app.toml @@ -0,0 +1,19 @@ +# Build a uvbox binary from a git repository. +# +# Usage: +# uvbox git git+https://github.com/VaasuDevanS/cowsay-python --config examples/git/simple-app.toml +# +# The git spec is passed through to `uv tool install --from` at runtime, so +# any form uv accepts works: @main, @v1.0.0, @, ssh:// URLs, etc. +# +# [package].name must match the distribution name the package registers +# (what `uv tool list` reports after installation). +# [package].script must match an entry from the package's [project.scripts]. + +[package] +name = "cowsay" +script = "cowsay" + +# To re-resolve the git ref on every run, uncomment: +# [package.version] +# auto-update = true