Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e9108a4
test: add regression guard for ComputeIdentifier pypi/wheel stability
hasansezertasan Apr 10, 2026
257ba50
feat(box): add GIT_SOURCE var and include it in identifier hash when set
hasansezertasan Apr 10, 2026
b867124
feat(box): add buildUvToolInstallFromArgs pure helper
hasansezertasan Apr 10, 2026
f7fbbf8
feat(box): add uvToolInstallGit runtime install path
hasansezertasan Apr 10, 2026
2821635
feat(box): dispatch to uvToolInstallGit when GIT_SOURCE is set
hasansezertasan Apr 10, 2026
46e728f
feat(boxer): add buildGoBuildLdflags pure helper and validateGitSource
hasansezertasan Apr 10, 2026
5406228
feat(boxer): add 'uvbox git' subcommand and wire ldflag helper into g…
hasansezertasan Apr 10, 2026
84ebbde
test(boxer): add unit tests for validateGitSource
hasansezertasan Apr 10, 2026
44568ad
docs(examples): add git source example configuration
hasansezertasan Apr 10, 2026
7922290
docs(readme): add git source feature and usage documentation
hasansezertasan Apr 10, 2026
0edafb7
fix(boxer,box): embed git source via file instead of ldflag
hasansezertasan Apr 10, 2026
597b8ac
chore(box): generate git_source.txt placeholder via box/generate.go
hasansezertasan Apr 10, 2026
8650c50
docs: point git example at cowsay and list it in README
hasansezertasan Apr 11, 2026
e11a26a
docs(readme): align git-source docs with actual PR #22 behavior
hasansezertasan Apr 11, 2026
13b68c3
docs(readme): note git runtime requirement for uvbox git builds
hasansezertasan Apr 11, 2026
eacaf5c
fix(box,boxer): address PR #22 review findings
hasansezertasan Apr 11, 2026
d646f8c
fix(box): address PR #22 review round 2
hasansezertasan Apr 13, 2026
b7de1c2
Merge branch 'main' into feat/git-subcommand
hasansezertasan Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

box/uvbox.toml
box/wheels
box/git_source.txt

boxer/boxes
boxer/boxer
Expand Down
71 changes: 64 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <spec> --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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
43 changes: 40 additions & 3 deletions box/box_package.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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))

Expand Down
110 changes: 110 additions & 0 deletions box/box_package_git.go
Original file line number Diff line number Diff line change
@@ -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 <spec>` 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 <spec>`. 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 <gitSource> <packageName> --upgrade`, optionally
// appending `--with-requirements <constraintsFile>`. 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 <GIT_SOURCE> <PackageName> --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
}
59 changes: 59 additions & 0 deletions box/box_package_git_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
38 changes: 38 additions & 0 deletions box/box_package_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
3 changes: 3 additions & 0 deletions box/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading