use anyhow::{Context, Result};
use pep440_rs::{Version, VersionSpecifiers};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::str::FromStr;
fn main() -> Result<()> {
let args = parse_args()?;
run(&args)
}
struct Args {
project: PathBuf,
output: PathBuf,
module: Option<String>,
script: Option<String>,
squashfs: Option<PathBuf>,
extra_uv_args: Vec<String>,
dev: bool,
ignore_version_mismatch: bool,
verbose: bool,
}
enum EntryPoint {
Module(String),
Script(String),
}
struct SquashfsEntry {
full_path: PathBuf,
name: String,
is_dir: bool,
link: Option<PathBuf>,
}
fn print_help() {
print!(
"\
Usage: uv-bundle --project=DIR --output=BINARY [OPTIONS]
Pack a uv-managed Python project into a self-executing ELF binary.
Required:
--project=DIR Directory containing pyproject.toml.
--output=BINARY Path for the produced self-executing ELF binary.
Options:
--module=MOD Python module passed to -m. Defaults to the package name
read from pyproject.toml. Mutually exclusive with --script.
--script=NAME Run the [project.scripts] console script NAME directly
(.venv/bin/NAME) instead of 'python -m'. Mutually
exclusive with --module.
--dev Include dev dependencies (default: --no-dev).
--squashfs=PATH Retain the intermediate squashfs at PATH instead of a
temporary file.
--uv-arg=ARG Extra argument forwarded to 'uv sync' (repeatable).
--ignore-version-mismatch
Continue with a warning (rather than failing) when the
system Python does not satisfy [project].requires-python.
--verbose Trace bundling: print squashfs tree and file sizes.
--help Show this message and exit.
uv-bundle always builds against the system Python (UV_PYTHON_PREFERENCE=system),
not a uv-managed download, so the bundled venv references a stable interpreter
path rather than one under the build user's home directory. Before building, the
system Python is checked against [project].requires-python; a mismatch is an
error unless --ignore-version-mismatch is given.
The pipeline runs four steps in order:
1. fuselage --dynamic-empty creates a private tmpfs at /run/fuselage/<name>.
2. uv sync installs the project's dependencies into that tmpfs.
3. mksquashfs compresses the tmpfs into a squashfs archive.
4. fuselage-bundle packs the archive into the output ELF.
Requires: fuselage (setuid), fuselage-bundle, uv, mksquashfs, gcc.
"
);
}
fn parse_args() -> Result<Args> {
let raw: Vec<String> = std::env::args().skip(1).collect();
if raw.iter().any(|a| a == "--help" || a == "-h") {
print_help();
std::process::exit(0);
}
parse_args_from(&raw)
}
fn parse_args_from(raw: &[String]) -> Result<Args> {
let mut project: Option<PathBuf> = None;
let mut output: Option<PathBuf> = None;
let mut module: Option<String> = None;
let mut script: Option<String> = None;
let mut squashfs: Option<PathBuf> = None;
let mut extra_uv_args: Vec<String> = Vec::new();
let mut dev = false;
let mut ignore_version_mismatch = false;
let mut verbose = false;
let mut i = 0;
while i < raw.len() {
let arg = &raw[i];
if let Some(val) = arg.strip_prefix("--project=") {
project = Some(PathBuf::from(val));
} else if arg == "--project" {
i += 1;
let val = raw.get(i).context("--project requires a value")?;
project = Some(PathBuf::from(val));
} else if let Some(val) = arg.strip_prefix("--output=") {
output = Some(PathBuf::from(val));
} else if arg == "--output" {
i += 1;
let val = raw.get(i).context("--output requires a value")?;
output = Some(PathBuf::from(val));
} else if let Some(val) = arg.strip_prefix("--module=") {
module = Some(val.to_owned());
} else if arg == "--module" {
i += 1;
let val = raw.get(i).context("--module requires a value")?;
module = Some(val.clone());
} else if let Some(val) = arg.strip_prefix("--script=") {
script = Some(val.to_owned());
} else if arg == "--script" {
i += 1;
let val = raw.get(i).context("--script requires a value")?;
script = Some(val.clone());
} else if let Some(val) = arg.strip_prefix("--squashfs=") {
squashfs = Some(PathBuf::from(val));
} else if arg == "--squashfs" {
i += 1;
let val = raw.get(i).context("--squashfs requires a value")?;
squashfs = Some(PathBuf::from(val));
} else if let Some(val) = arg.strip_prefix("--uv-arg=") {
extra_uv_args.push(val.to_owned());
} else if arg == "--uv-arg" {
i += 1;
let val = raw.get(i).context("--uv-arg requires a value")?;
extra_uv_args.push(val.clone());
} else if arg == "--dev" {
dev = true;
} else if arg == "--ignore-version-mismatch" {
ignore_version_mismatch = true;
} else if arg == "--verbose" {
verbose = true;
} else {
anyhow::bail!("unrecognised argument: {arg:?}");
}
i += 1;
}
let project = project.context("--project is required")?;
let output = output.context("--output is required")?;
if !project.join("pyproject.toml").is_file() {
anyhow::bail!("no pyproject.toml found in {}", project.display());
}
if module.is_some() && script.is_some() {
anyhow::bail!("--module and --script are mutually exclusive");
}
Ok(Args {
project,
output,
module,
script,
squashfs,
extra_uv_args,
dev,
ignore_version_mismatch,
verbose,
})
}
fn run(args: &Args) -> Result<()> {
let package_name = resolve_package_name(args)?;
let entry_point = resolve_entry_point(args, &package_name)?;
let mount_point = format!("/run/fuselage/{package_name}");
check_system_python(args)?;
let squashfs_owned;
let squashfs_path: &Path = match &args.squashfs {
Some(p) => p.as_path(),
None => {
squashfs_owned =
std::env::temp_dir().join(format!("_uv_bundle_{}.sfs", std::process::id()));
squashfs_owned.as_path()
}
};
run_mount_sync_compress(
&mount_point,
&args.project,
squashfs_path,
&args.extra_uv_args,
args.dev,
)?;
if args.verbose {
let sq_size = std::fs::metadata(squashfs_path)
.map(|m| m.len())
.unwrap_or(0);
eprintln!("uv-bundle: squashfs contents ({}):", format_size(sq_size));
if let Err(e) = print_squashfs_tree(squashfs_path) {
eprintln!("uv-bundle: warning: could not list squashfs: {e}");
}
}
let pack_result = run_fuselage_bundle(squashfs_path, &args.output, &mount_point, &entry_point);
if args.verbose && pack_result.is_ok() {
let sq_size = std::fs::metadata(squashfs_path)
.map(|m| m.len())
.unwrap_or(0);
let out_size = std::fs::metadata(&args.output)
.map(|m| m.len())
.unwrap_or(0);
let stub_size = out_size.saturating_sub(sq_size);
eprintln!("uv-bundle: stub+pad {}", format_size(stub_size));
eprintln!("uv-bundle: squashfs {}", format_size(sq_size));
eprintln!("uv-bundle: output {}", format_size(out_size));
}
if args.squashfs.is_none() {
if let Err(e) = std::fs::remove_file(squashfs_path) {
eprintln!(
"warning: could not remove temp squashfs {}: {e}",
squashfs_path.display()
);
}
}
pack_result
}
fn resolve_package_name(args: &Args) -> Result<String> {
let pyproject = args.project.join("pyproject.toml");
let content = std::fs::read_to_string(&pyproject)
.with_context(|| format!("failed to read {}", pyproject.display()))?;
let doc: toml::Value = content
.parse()
.with_context(|| format!("failed to parse {}", pyproject.display()))?;
let name = doc
.get("project")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.with_context(|| format!("{}: missing [project].name", pyproject.display()))?;
let normalised = name.replace(['-', '.'], "_");
if normalised.is_empty()
|| normalised == "."
|| normalised == ".."
|| normalised.contains([':', '/'])
{
anyhow::bail!(
"{}: [project].name {:?} normalises to {:?}, which is not a usable mount name \
(must be a single path component with no ':' or '/')",
pyproject.display(),
name,
normalised
);
}
Ok(normalised)
}
fn resolve_entry_point(args: &Args, package_name: &str) -> Result<EntryPoint> {
if let Some(script) = &args.script {
validate_script(&args.project, script)?;
Ok(EntryPoint::Script(script.clone()))
} else if let Some(module) = &args.module {
Ok(EntryPoint::Module(module.clone()))
} else {
Ok(EntryPoint::Module(package_name.to_owned()))
}
}
fn validate_script(project: &Path, name: &str) -> Result<()> {
if name.is_empty() || name == "." || name == ".." || name.contains([':', '/']) {
anyhow::bail!(
"--script {name:?} is not a valid console-script name \
(must be a single path component with no ':' or '/')"
);
}
let pyproject = project.join("pyproject.toml");
let content = std::fs::read_to_string(&pyproject)
.with_context(|| format!("failed to read {}", pyproject.display()))?;
let doc: toml::Value = content
.parse()
.with_context(|| format!("failed to parse {}", pyproject.display()))?;
let scripts = doc
.get("project")
.and_then(|p| p.get("scripts"))
.and_then(|s| s.as_table())
.with_context(|| {
format!(
"{}: --script={name:?} given but [project.scripts] is not defined",
pyproject.display()
)
})?;
if scripts.contains_key(name) {
return Ok(());
}
let mut available: Vec<&str> = scripts.keys().map(String::as_str).collect();
available.sort_unstable();
anyhow::bail!(
"{}: --script={name:?} is not in [project.scripts] (available: {})",
pyproject.display(),
available.join(", ")
);
}
fn read_requires_python(project: &Path) -> Result<Option<String>> {
let pyproject = project.join("pyproject.toml");
let content = std::fs::read_to_string(&pyproject)
.with_context(|| format!("failed to read {}", pyproject.display()))?;
let doc: toml::Value = content
.parse()
.with_context(|| format!("failed to parse {}", pyproject.display()))?;
match doc.get("project").and_then(|p| p.get("requires-python")) {
None => Ok(None),
Some(toml::Value::String(s)) => Ok(Some(s.to_owned())),
Some(_) => anyhow::bail!(
"{}: [project].requires-python must be a string",
pyproject.display()
),
}
}
fn system_python_version() -> Result<Version> {
let find = Command::new("uv")
.args(["python", "find", "--system"])
.output()
.context("failed to run 'uv python find --system' — is uv installed and on PATH?")?;
if !find.status.success() {
anyhow::bail!(
"uv could not find a system Python (UV_PYTHON_PREFERENCE=system): {}",
String::from_utf8_lossy(&find.stderr).trim()
);
}
let interpreter = String::from_utf8(find.stdout)
.context("'uv python find --system' produced non-UTF-8 output")?
.trim()
.to_owned();
let ver = Command::new(&interpreter)
.args(["-c", "import sys; print('%d.%d.%d' % sys.version_info[:3])"])
.output()
.with_context(|| format!("failed to run system Python at {interpreter}"))?;
if !ver.status.success() {
anyhow::bail!(
"system Python at {interpreter} failed to report its version: {}",
String::from_utf8_lossy(&ver.stderr).trim()
);
}
let version_str = String::from_utf8(ver.stdout)
.context("system Python produced non-UTF-8 version output")?
.trim()
.to_owned();
Version::from_str(&version_str)
.with_context(|| format!("could not parse system Python version {version_str:?}"))
}
fn check_system_python(args: &Args) -> Result<()> {
let requires = match read_requires_python(&args.project)? {
Some(r) => r,
None => return Ok(()),
};
let specifiers = VersionSpecifiers::from_str(&requires).with_context(|| {
format!("could not parse [project].requires-python {requires:?} as a PEP 440 specifier")
})?;
let version = system_python_version()?;
if specifiers.contains(&version) {
return Ok(());
}
if args.ignore_version_mismatch {
eprintln!(
"uv-bundle: warning: system Python {version} does not satisfy \
requires-python {requires:?}; continuing because --ignore-version-mismatch was given"
);
Ok(())
} else {
anyhow::bail!(
"system Python {version} does not satisfy [project].requires-python {requires:?}.\n\
Install a compatible Python, or pass --ignore-version-mismatch to build anyway."
)
}
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn run_mount_sync_compress(
mount_point: &str,
project: &Path,
squashfs_path: &Path,
extra_uv_args: &[String],
dev: bool,
) -> Result<()> {
let project_str = project
.to_str()
.with_context(|| format!("project path is not valid UTF-8: {}", project.display()))?;
let squashfs_str = squashfs_path.to_str().with_context(|| {
format!(
"squashfs path is not valid UTF-8: {}",
squashfs_path.display()
)
})?;
let mut uv_flags: Vec<String> = Vec::new();
if !dev {
uv_flags.push("--no-dev".to_owned());
}
uv_flags.extend(extra_uv_args.iter().map(|a| shell_quote(a)));
let uv_flags_str = uv_flags.join(" ");
let sh_cmd = format!(
"UV_PYTHON_PREFERENCE=system UV_PROJECT_ENVIRONMENT={} \
uv sync --project {} {} && mksquashfs {} {} -noappend -quiet",
shell_quote(&format!("{mount_point}/.venv")),
shell_quote(project_str),
uv_flags_str,
shell_quote(mount_point),
shell_quote(squashfs_str),
);
let status = Command::new("fuselage")
.arg(format!("--dynamic-empty={mount_point}"))
.arg("--")
.arg("sh")
.arg("-c")
.arg(&sh_cmd)
.status()
.context("failed to run fuselage — is it installed and on PATH?")?;
if !status.success() {
anyhow::bail!("fuselage/uv sync/mksquashfs pipeline failed");
}
Ok(())
}
fn run_fuselage_bundle(
squashfs_path: &Path,
output: &Path,
mount_point: &str,
entry_point: &EntryPoint,
) -> Result<()> {
let mut cmd = Command::new("fuselage-bundle");
cmd.arg(format!("--archive={}", squashfs_path.display()))
.arg(format!("--output={}", output.display()))
.arg("--")
.arg(format!("--static={mount_point}:/proc/self/exe"));
match entry_point {
EntryPoint::Module(module) => {
cmd.arg(format!("--run={mount_point}/.venv/bin/python"))
.arg("--")
.arg("-m")
.arg(module);
}
EntryPoint::Script(script) => {
cmd.arg(format!("--run={mount_point}/.venv/bin/{script}"));
}
}
let status = cmd
.status()
.context("failed to run fuselage-bundle — is it installed and on PATH?")?;
if !status.success() {
anyhow::bail!("fuselage-bundle failed");
}
println!("uv-bundle: wrote {}", output.display());
Ok(())
}
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{bytes} B")
}
}
fn print_squashfs_tree(squashfs_path: &Path) -> Result<()> {
use backhand::{FilesystemReader, InnerNode};
let file = std::fs::File::open(squashfs_path)
.with_context(|| format!("cannot open {}", squashfs_path.display()))?;
let reader = FilesystemReader::from_reader_with_offset(std::io::BufReader::new(file), 0)
.with_context(|| format!("cannot read squashfs {}", squashfs_path.display()))?;
let mut entries: Vec<SquashfsEntry> = Vec::new();
for node in reader.files() {
let full_path = PathBuf::from(&node.fullpath);
if full_path == Path::new("/") {
continue;
}
let name = full_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| full_path.to_string_lossy().into_owned());
let is_dir = matches!(node.inner, InnerNode::Dir(_));
let link = if let InnerNode::Symlink(sym) = &node.inner {
Some(PathBuf::from(&sym.link))
} else {
None
};
entries.push(SquashfsEntry {
full_path,
name,
is_dir,
link,
});
}
let mut children: BTreeMap<PathBuf, Vec<usize>> = BTreeMap::new();
for (i, entry) in entries.iter().enumerate() {
let parent = entry
.full_path
.parent()
.unwrap_or(Path::new("/"))
.to_path_buf();
children.entry(parent).or_default().push(i);
}
for kids in children.values_mut() {
kids.sort_by(|&a, &b| entries[a].name.cmp(&entries[b].name));
}
eprintln!(".");
print_squashfs_subtree(Path::new("/"), &entries, &children, "");
Ok(())
}
fn print_squashfs_subtree(
parent: &Path,
entries: &[SquashfsEntry],
children: &BTreeMap<PathBuf, Vec<usize>>,
prefix: &str,
) {
let Some(kids) = children.get(parent) else {
return;
};
let count = kids.len();
for (pos, &idx) in kids.iter().enumerate() {
let entry = &entries[idx];
let is_last = pos + 1 == count;
let connector = if is_last { "└── " } else { "├── " };
let display_name = if entry.is_dir {
format!("{}/", entry.name)
} else if let Some(ref link) = entry.link {
format!("{} -> {}", entry.name, link.display())
} else {
entry.name.clone()
};
eprintln!("{prefix}{connector}{display_name}");
let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
print_squashfs_subtree(&entry.full_path, entries, children, &child_prefix);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn ss(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
fn write_pyproject(dir: &Path, name: &str) {
std::fs::write(
dir.join("pyproject.toml"),
format!("[project]\nname = \"{name}\"\n"),
)
.unwrap();
}
#[test]
fn shell_quote_plain() {
assert_eq!(shell_quote("hello"), "'hello'");
}
#[test]
fn shell_quote_spaces() {
assert_eq!(shell_quote("my project"), "'my project'");
}
#[test]
fn shell_quote_single_quote() {
assert_eq!(shell_quote("it's"), "'it'\\''s'");
}
#[test]
fn shell_quote_empty() {
assert_eq!(shell_quote(""), "''");
}
#[test]
fn parse_args_basic() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[
&format!("--project={}", tmp.path().display()),
"--output=/out/app",
]);
let args = parse_args_from(&raw).unwrap();
assert_eq!(args.output, PathBuf::from("/out/app"));
assert!(args.module.is_none());
}
#[test]
fn parse_args_space_separated_flags() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let proj = tmp.path().to_str().unwrap().to_owned();
let raw = ss(&["--project", &proj, "--output", "/out/app"]);
let args = parse_args_from(&raw).unwrap();
assert_eq!(args.output, PathBuf::from("/out/app"));
}
#[test]
fn parse_args_module_override() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[
&format!("--project={}", tmp.path().display()),
"--output=/out",
"--module=mymod",
]);
let args = parse_args_from(&raw).unwrap();
assert_eq!(args.module.as_deref(), Some("mymod"));
}
#[test]
fn parse_args_module_space_separated() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[
&format!("--project={}", tmp.path().display()),
"--output=/out",
"--module",
"mymod",
]);
let args = parse_args_from(&raw).unwrap();
assert_eq!(args.module.as_deref(), Some("mymod"));
}
#[test]
fn parse_args_script_override() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[
&format!("--project={}", tmp.path().display()),
"--output=/out",
"--script=greet",
]);
let args = parse_args_from(&raw).unwrap();
assert_eq!(args.script.as_deref(), Some("greet"));
}
#[test]
fn parse_args_script_space_separated() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[
&format!("--project={}", tmp.path().display()),
"--output=/out",
"--script",
"greet",
]);
let args = parse_args_from(&raw).unwrap();
assert_eq!(args.script.as_deref(), Some("greet"));
}
#[test]
fn parse_args_module_and_script_are_mutually_exclusive() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[
&format!("--project={}", tmp.path().display()),
"--output=/out",
"--module=mymod",
"--script=greet",
]);
assert!(parse_args_from(&raw).is_err());
}
#[test]
fn parse_args_uv_args_accumulated() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[
&format!("--project={}", tmp.path().display()),
"--output=/out",
"--uv-arg=--frozen",
"--uv-arg=--no-dev",
]);
let args = parse_args_from(&raw).unwrap();
assert_eq!(args.extra_uv_args, vec!["--frozen", "--no-dev"]);
}
#[test]
fn parse_args_uv_arg_space_separated() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[
&format!("--project={}", tmp.path().display()),
"--output=/out",
"--uv-arg",
"--frozen",
]);
let args = parse_args_from(&raw).unwrap();
assert_eq!(args.extra_uv_args, vec!["--frozen"]);
}
#[test]
fn parse_args_squashfs_explicit() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[
&format!("--project={}", tmp.path().display()),
"--output=/out",
"--squashfs=/tmp/a.sfs",
]);
let args = parse_args_from(&raw).unwrap();
assert_eq!(args.squashfs.as_deref(), Some(Path::new("/tmp/a.sfs")));
}
#[test]
fn parse_args_missing_project_is_error() {
let raw = ss(&["--output=/out"]);
assert!(parse_args_from(&raw).is_err());
}
#[test]
fn parse_args_missing_output_is_error() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[&format!("--project={}", tmp.path().display())]);
assert!(parse_args_from(&raw).is_err());
}
#[test]
fn parse_args_unknown_flag_is_error() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[
&format!("--project={}", tmp.path().display()),
"--output=/out",
"--bogus=x",
]);
assert!(parse_args_from(&raw).is_err());
}
#[test]
fn parse_args_dev_defaults_to_false() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[
&format!("--project={}", tmp.path().display()),
"--output=/out",
]);
let args = parse_args_from(&raw).unwrap();
assert!(!args.dev);
}
#[test]
fn parse_args_dev_flag_sets_dev_true() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[
&format!("--project={}", tmp.path().display()),
"--output=/out",
"--dev",
]);
let args = parse_args_from(&raw).unwrap();
assert!(args.dev);
}
#[test]
fn parse_args_verbose_defaults_to_false() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[
&format!("--project={}", tmp.path().display()),
"--output=/out",
]);
let args = parse_args_from(&raw).unwrap();
assert!(!args.verbose);
}
#[test]
fn parse_args_verbose_flag_sets_verbose_true() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
let raw = ss(&[
&format!("--project={}", tmp.path().display()),
"--output=/out",
"--verbose",
]);
let args = parse_args_from(&raw).unwrap();
assert!(args.verbose);
}
#[test]
fn parse_args_no_pyproject_is_error() {
let raw = ss(&["--project=/tmp", "--output=/out"]);
assert!(parse_args_from(&raw).is_err());
}
fn args_with_project(dir: &Path) -> Args {
Args {
project: dir.to_path_buf(),
output: PathBuf::from("/out"),
module: None,
script: None,
squashfs: None,
extra_uv_args: vec![],
dev: false,
ignore_version_mismatch: false,
verbose: false,
}
}
#[test]
fn resolve_name_reads_pyproject() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "myapp");
let args = args_with_project(tmp.path());
assert_eq!(resolve_package_name(&args).unwrap(), "myapp");
}
#[test]
fn resolve_name_normalises_hyphens() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "my-cool-app");
let args = args_with_project(tmp.path());
assert_eq!(resolve_package_name(&args).unwrap(), "my_cool_app");
}
#[test]
fn resolve_name_normalises_dots() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "my.pkg");
let args = args_with_project(tmp.path());
assert_eq!(resolve_package_name(&args).unwrap(), "my_pkg");
}
#[test]
fn resolve_name_missing_project_section_is_error() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("pyproject.toml"), "[tool.x]\nfoo=\"bar\"\n").unwrap();
let args = args_with_project(tmp.path());
assert!(resolve_package_name(&args).is_err());
}
#[test]
fn resolve_name_invalid_toml_is_error() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("pyproject.toml"), "not valid toml {{{{").unwrap();
let args = args_with_project(tmp.path());
assert!(resolve_package_name(&args).is_err());
}
#[test]
fn resolve_name_missing_name_key_is_error() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("pyproject.toml"),
"[project]\nversion=\"1.0\"\n",
)
.unwrap();
let args = args_with_project(tmp.path());
assert!(resolve_package_name(&args).is_err());
}
fn write_pyproject_with_scripts(dir: &Path, name: &str, scripts: &[(&str, &str)]) {
let mut content = format!("[project]\nname = \"{name}\"\n\n[project.scripts]\n");
for (k, v) in scripts {
content.push_str(&format!("{k} = \"{v}\"\n"));
}
std::fs::write(dir.join("pyproject.toml"), content).unwrap();
}
#[test]
fn validate_script_accepts_declared_script() {
let tmp = TempDir::new().unwrap();
write_pyproject_with_scripts(tmp.path(), "x", &[("greet", "x.cli:main")]);
assert!(validate_script(tmp.path(), "greet").is_ok());
}
#[test]
fn validate_script_rejects_undeclared_script() {
let tmp = TempDir::new().unwrap();
write_pyproject_with_scripts(tmp.path(), "x", &[("greet", "x.cli:main")]);
assert!(validate_script(tmp.path(), "nope").is_err());
}
#[test]
fn validate_script_rejects_when_no_scripts_section() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
assert!(validate_script(tmp.path(), "greet").is_err());
}
#[test]
fn validate_script_rejects_unsafe_name() {
let tmp = TempDir::new().unwrap();
write_pyproject_with_scripts(tmp.path(), "x", &[("greet", "x.cli:main")]);
assert!(validate_script(tmp.path(), "../evil").is_err());
assert!(validate_script(tmp.path(), "a/b").is_err());
assert!(validate_script(tmp.path(), "").is_err());
}
#[test]
fn entry_point_defaults_to_module_on_package_name() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "myapp");
let args = args_with_project(tmp.path());
match resolve_entry_point(&args, "myapp").unwrap() {
EntryPoint::Module(m) => assert_eq!(m, "myapp"),
EntryPoint::Script(_) => panic!("expected Module default"),
}
}
#[test]
fn entry_point_uses_module_override() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "myapp");
let mut args = args_with_project(tmp.path());
args.module = Some("myapp.cli".to_owned());
match resolve_entry_point(&args, "myapp").unwrap() {
EntryPoint::Module(m) => assert_eq!(m, "myapp.cli"),
EntryPoint::Script(_) => panic!("expected Module"),
}
}
#[test]
fn entry_point_uses_validated_script() {
let tmp = TempDir::new().unwrap();
write_pyproject_with_scripts(tmp.path(), "myapp", &[("greet", "myapp.cli:main")]);
let mut args = args_with_project(tmp.path());
args.script = Some("greet".to_owned());
match resolve_entry_point(&args, "myapp").unwrap() {
EntryPoint::Script(s) => assert_eq!(s, "greet"),
EntryPoint::Module(_) => panic!("expected Script"),
}
}
#[test]
fn requires_python_present() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("pyproject.toml"),
"[project]\nname=\"x\"\nrequires-python=\">=3.9\"\n",
)
.unwrap();
assert_eq!(
read_requires_python(tmp.path()).unwrap(),
Some(">=3.9".to_owned())
);
}
#[test]
fn requires_python_absent_is_none() {
let tmp = TempDir::new().unwrap();
write_pyproject(tmp.path(), "x");
assert_eq!(read_requires_python(tmp.path()).unwrap(), None);
}
fn satisfies(spec: &str, version: &str) -> bool {
VersionSpecifiers::from_str(spec)
.unwrap()
.contains(&Version::from_str(version).unwrap())
}
#[test]
fn specifier_lower_bound() {
assert!(satisfies(">=3.9", "3.12.0"));
assert!(!satisfies(">=3.9", "3.8.10"));
}
#[test]
fn specifier_range() {
assert!(satisfies(">=3.9,<3.13", "3.12.5"));
assert!(!satisfies(">=3.9,<3.13", "3.13.0"));
}
#[test]
fn specifier_compatible_release() {
assert!(satisfies("~=3.11", "3.11.4"));
assert!(satisfies("~=3.11", "3.99.0"));
assert!(!satisfies("~=3.11.0", "3.12.0"));
}
#[test]
fn specifier_exclusion() {
assert!(!satisfies("!=3.10.*", "3.10.4"));
assert!(satisfies("!=3.10.*", "3.11.0"));
}
}