diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 8cdbde832203..286bc1428f40 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -11,7 +11,7 @@ use url::Url; use uv_cache::CacheArgs; use uv_configuration::{ ConfigSettingEntry, ExportFormat, IndexStrategy, KeyringProviderType, PackageNameSpecifier, - TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem, + ProjectBuildBackend, TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem, }; use uv_distribution_types::{FlatIndexLocation, IndexUrl}; use uv_normalize::{ExtraName, PackageName}; @@ -2461,6 +2461,10 @@ pub struct InitArgs { #[arg(long, value_enum, conflicts_with = "script")] pub vcs: Option, + /// Initialize a build-backend of choice for the project. + #[arg(long, value_enum, conflicts_with_all=["script", "no_package"])] + pub build_backend: Option, + /// Do not create a `README.md` file. #[arg(long)] pub no_readme: bool, diff --git a/crates/uv-configuration/src/lib.rs b/crates/uv-configuration/src/lib.rs index fbd40a32beb8..90cdfb0c6ae5 100644 --- a/crates/uv-configuration/src/lib.rs +++ b/crates/uv-configuration/src/lib.rs @@ -13,6 +13,7 @@ pub use name_specifiers::*; pub use overrides::*; pub use package_options::*; pub use preview::*; +pub use project_build_backend::*; pub use sources::*; pub use target_triple::*; pub use trusted_host::*; @@ -34,6 +35,7 @@ mod name_specifiers; mod overrides; mod package_options; mod preview; +mod project_build_backend; mod sources; mod target_triple; mod trusted_host; diff --git a/crates/uv-configuration/src/project_build_backend.rs b/crates/uv-configuration/src/project_build_backend.rs new file mode 100644 index 000000000000..de5623077279 --- /dev/null +++ b/crates/uv-configuration/src/project_build_backend.rs @@ -0,0 +1,20 @@ +/// Available project build backends for use in `pyproject.toml`. +#[derive(Clone, Copy, Debug, PartialEq, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum ProjectBuildBackend { + #[default] + /// Use [hatchling](https://pypi.org/project/hatchling) as the project build backend. + Hatch, + /// Use [flit-core](https://pypi.org/project/flit-core) as the project build backend. + Flit, + /// Use [pdm-backend](https://pypi.org/project/pdm-backend) as the project build backend. + PDM, + /// Use [setuptools](https://pypi.org/project/setuptools) as the project build backend. + Setuptools, + /// Use [maturin](https://pypi.org/project/maturin) as the project build backend. + Maturin, + /// Use [scikit-build-core](https://pypi.org/project/scikit-build-core) as the project build backend. + Scikit, +} diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 4e2b8fc8159e..4814609a9a9f 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -9,7 +9,7 @@ use tracing::{debug, warn}; use uv_cache::Cache; use uv_cli::AuthorFrom; use uv_client::{BaseClientBuilder, Connectivity}; -use uv_configuration::{VersionControlError, VersionControlSystem}; +use uv_configuration::{ProjectBuildBackend, VersionControlError, VersionControlSystem}; use uv_fs::{Simplified, CWD}; use uv_git::GIT; use uv_pep440::Version; @@ -38,6 +38,7 @@ pub(crate) async fn init( package: bool, init_kind: InitKind, vcs: Option, + build_backend: Option, no_readme: bool, author_from: Option, no_pin_python: bool, @@ -115,6 +116,7 @@ pub(crate) async fn init( package, project_kind, vcs, + build_backend, no_readme, author_from, no_pin_python, @@ -246,6 +248,7 @@ async fn init_project( package: bool, project_kind: InitProjectKind, vcs: Option, + build_backend: Option, no_readme: bool, author_from: Option, no_pin_python: bool, @@ -486,6 +489,7 @@ async fn init_project( &requires_python, python_request.as_ref(), vcs, + build_backend, author_from, no_readme, package, @@ -576,6 +580,7 @@ impl InitProjectKind { requires_python: &RequiresPython, python_request: Option<&PythonRequest>, vcs: Option, + build_backend: Option, author_from: Option, no_readme: bool, package: bool, @@ -588,6 +593,7 @@ impl InitProjectKind { requires_python, python_request, vcs, + build_backend, author_from, no_readme, package, @@ -601,6 +607,7 @@ impl InitProjectKind { requires_python, python_request, vcs, + build_backend, author_from, no_readme, package, @@ -618,6 +625,7 @@ impl InitProjectKind { requires_python: &RequiresPython, python_request: Option<&PythonRequest>, vcs: Option, + build_backend: Option, author_from: Option, no_readme: bool, package: bool, @@ -644,25 +652,13 @@ impl InitProjectKind { pyproject.push_str(&pyproject_project_scripts(name, name.as_str(), "main")); // Add a build system + let build_backend = build_backend.unwrap_or_default(); pyproject.push('\n'); - pyproject.push_str(pyproject_build_system()); - } + pyproject.push_str(&pyproject_build_system(name, build_backend)); + pyproject_build_backend_prerequisites(name, path, build_backend)?; - // Create the source structure. - if package { - // Create `src/{name}/__init__.py`, if it doesn't exist already. - let src_dir = path.join("src").join(&*name.as_dist_info_name()); - let init_py = src_dir.join("__init__.py"); - if !init_py.try_exists()? { - fs_err::create_dir_all(&src_dir)?; - fs_err::write( - init_py, - indoc::formatdoc! {r#" - def main() -> None: - print("Hello from {name}!") - "#}, - )?; - } + // Generate `src` files + generate_package_scripts(name, path, build_backend, false)?; } else { // Create `hello.py` if it doesn't exist // TODO(zanieb): Only create `hello.py` if there are no other Python files? @@ -710,6 +706,7 @@ impl InitProjectKind { requires_python: &RequiresPython, python_request: Option<&PythonRequest>, vcs: Option, + build_backend: Option, author_from: Option, no_readme: bool, package: bool, @@ -726,31 +723,15 @@ impl InitProjectKind { let mut pyproject = pyproject_project(name, requires_python, author.as_ref(), no_readme); // Always include a build system if the project is packaged. + let build_backend = build_backend.unwrap_or_default(); pyproject.push('\n'); - pyproject.push_str(pyproject_build_system()); + pyproject.push_str(&pyproject_build_system(name, build_backend)); + pyproject_build_backend_prerequisites(name, path, build_backend)?; fs_err::write(path.join("pyproject.toml"), pyproject)?; - // Create `src/{name}/__init__.py`, if it doesn't exist already. - let src_dir = path.join("src").join(&*name.as_dist_info_name()); - fs_err::create_dir_all(&src_dir)?; - - let init_py = src_dir.join("__init__.py"); - if !init_py.try_exists()? { - fs_err::write( - init_py, - indoc::formatdoc! {r#" - def hello() -> str: - return "Hello from {name}!" - "#}, - )?; - } - - // Create a `py.typed` file - let py_typed = src_dir.join("py.typed"); - if !py_typed.try_exists()? { - fs_err::write(py_typed, "")?; - } + // Generate `src` files + generate_package_scripts(name, path, build_backend, true)?; // Write .python-version if it doesn't exist. if let Some(python_request) = python_request { @@ -807,18 +788,63 @@ fn pyproject_project( dependencies = [] "#, readme = if no_readme { "" } else { "\nreadme = \"README.md\"" }, - authors = author.map_or_else(String::new, |author| format!("\nauthors = [\n {} \n]", author.to_toml_string())), + authors = author.map_or_else(String::new, |author| format!("\nauthors = [\n {}\n]", author.to_toml_string())), requires_python = requires_python.specifiers(), } } /// Generate the `[build-system]` section of a `pyproject.toml`. -fn pyproject_build_system() -> &'static str { - indoc::indoc! {r#" - [build-system] - requires = ["hatchling"] - build-backend = "hatchling.build" - "#} +/// Generate the `[tool.]` section of a `pyproject.toml` where applicable. +fn pyproject_build_system(package: &PackageName, build_backend: ProjectBuildBackend) -> String { + let module_name = package.as_dist_info_name(); + match build_backend { + // Pure-python backends + ProjectBuildBackend::Hatch => indoc::indoc! {r#" + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "#} + .to_string(), + ProjectBuildBackend::Flit => indoc::indoc! {r#" + [build-system] + requires = ["flit_core>=3.2,<4"] + build-backend = "flit_core.buildapi" + "#} + .to_string(), + ProjectBuildBackend::PDM => indoc::indoc! {r#" + [build-system] + requires = ["pdm-backend"] + build-backend = "pdm.backend" + "#} + .to_string(), + ProjectBuildBackend::Setuptools => indoc::indoc! {r#" + [build-system] + requires = ["setuptools>=61"] + build-backend = "setuptools.build_meta" + "#} + .to_string(), + // Binary build backends + ProjectBuildBackend::Maturin => indoc::formatdoc! {r#" + [tool.maturin] + module-name = "{module_name}._core" + python-packages = ["{module_name}"] + python-source = "src" + + [build-system] + requires = ["maturin>=1.0,<2.0"] + build-backend = "maturin" + "#}, + ProjectBuildBackend::Scikit => indoc::indoc! {r#" + [tool.scikit-build] + minimum-version = "build-system.requires" + build-dir = "build/{wheel_tag}" + + [build-system] + requires = ["scikit-build-core>=0.10", "pybind11"] + build-backend = "scikit_build_core.build" + "#} + .to_string(), + } } /// Generate the `[project.scripts]` section of a `pyproject.toml`. @@ -830,6 +856,198 @@ fn pyproject_project_scripts(package: &PackageName, executable_name: &str, targe "#} } +/// Generate additional files as needed for specific build backends. +fn pyproject_build_backend_prerequisites( + package: &PackageName, + path: &Path, + build_backend: ProjectBuildBackend, +) -> Result<()> { + let module_name = package.as_dist_info_name(); + match build_backend { + ProjectBuildBackend::Maturin => { + // Generate Cargo.toml + let build_file = path.join("Cargo.toml"); + if !build_file.try_exists()? { + fs_err::write( + build_file, + indoc::formatdoc! {r#" + [package] + name = "{module_name}" + version = "0.1.0" + edition = "2021" + + [lib] + name = "_core" + # "cdylib" is necessary to produce a shared library for Python to import from. + crate-type = ["cdylib"] + + [dependencies] + # "extension-module" tells pyo3 we want to build an extension module (skips linking against libpython.so) + # "abi3-py39" tells pyo3 (and maturin) to build using the stable ABI with minimum Python version 3.9 + pyo3 = {{ version = "0.22.4", features = ["extension-module", "abi3-py39"] }} + "#}, + )?; + } + } + ProjectBuildBackend::Scikit => { + // Generate CMakeLists.txt + let build_file = path.join("CMakeLists.txt"); + if !build_file.try_exists()? { + fs_err::write( + build_file, + indoc::formatdoc! {r#" + cmake_minimum_required(VERSION 3.15) + project(${{SKBUILD_PROJECT_NAME}} LANGUAGES CXX) + + set(PYBIND11_FINDPYTHON ON) + find_package(pybind11 CONFIG REQUIRED) + + pybind11_add_module(_core MODULE src/main.cpp) + install(TARGETS _core DESTINATION ${{SKBUILD_PROJECT_NAME}}) + "#}, + )?; + } + } + _ => {} + } + Ok(()) +} + +/// Generate startup scripts for a package-based application or library. +fn generate_package_scripts( + package: &PackageName, + path: &Path, + build_backend: ProjectBuildBackend, + is_lib: bool, +) -> Result<()> { + let module_name = package.as_dist_info_name(); + + let src_dir = path.join("src"); + let pkg_dir = src_dir.join(&*module_name); + fs_err::create_dir_all(&pkg_dir)?; + + // Python script for pure-python packaged apps or libs + let pure_python_script = if is_lib { + indoc::formatdoc! {r#" + def hello() -> str: + return "Hello from {package}!" + "#} + } else { + indoc::formatdoc! {r#" + def main() -> None: + print("Hello from {package}!") + "#} + }; + + // Python script for binary-based packaged apps or libs + let binary_call_script = if is_lib { + indoc::formatdoc! {r#" + from {module_name}._core import hello_from_bin + + def hello() -> str: + return hello_from_bin() + "#} + } else { + indoc::formatdoc! {r#" + from {module_name}._core import hello_from_bin + + def main() -> None: + print(hello_from_bin()) + "#} + }; + + // .pyi file for binary script + let pyi_contents = indoc::indoc! {r" + from __future__ import annotations + + def hello_from_bin() -> str: ... + "}; + + let package_script = match build_backend { + ProjectBuildBackend::Maturin => { + // Generate lib.rs + let native_src = src_dir.join("lib.rs"); + if !native_src.try_exists()? { + fs_err::write( + native_src, + indoc::formatdoc! {r#" + use pyo3::prelude::*; + + #[pyfunction] + fn hello_from_bin() -> String {{ + return "Hello from {package}!".to_string(); + }} + + /// A Python module implemented in Rust. The name of this function must match + /// the `lib.name` setting in the `Cargo.toml`, else Python will not be able to + /// import the module. + #[pymodule] + fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> {{ + m.add_function(wrap_pyfunction!(hello_from_bin, m)?)?; + Ok(()) + }} + "#}, + )?; + } + // Generate .pyi file + let pyi_file = pkg_dir.join("_core.pyi"); + if !pyi_file.try_exists()? { + fs_err::write(pyi_file, pyi_contents)?; + }; + // Return python script calling binary + binary_call_script + } + ProjectBuildBackend::Scikit => { + // Generate main.cpp + let native_src = src_dir.join("main.cpp"); + if !native_src.try_exists()? { + fs_err::write( + native_src, + indoc::formatdoc! {r#" + #include + + std::string hello_from_bin() {{ return "Hello from {package}!"; }} + + namespace py = pybind11; + + PYBIND11_MODULE(_core, m) {{ + m.doc() = "pybind11 hello module"; + + m.def("hello_from_bin", &hello_from_bin, R"pbdoc( + A function that returns a Hello string. + )pbdoc"); + }} + "#}, + )?; + } + // Generate .pyi file + let pyi_file = pkg_dir.join("_core.pyi"); + if !pyi_file.try_exists()? { + fs_err::write(pyi_file, pyi_contents)?; + }; + // Return python script calling binary + binary_call_script + } + _ => pure_python_script, + }; + + // Create `src/{name}/__init__.py`, if it doesn't exist already. + let init_py = pkg_dir.join("__init__.py"); + if !init_py.try_exists()? { + fs_err::write(init_py, package_script)?; + } + + // Create `src/{name}/py.typed`, if it doesn't exist already. + if is_lib { + let py_typed = pkg_dir.join("py.typed"); + if !py_typed.try_exists()? { + fs_err::write(py_typed, "")?; + } + } + + Ok(()) +} + /// Initialize the version control system at the given path. fn init_vcs(path: &Path, vcs: Option) -> Result<()> { // Detect any existing version control system. diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index e6e444cccd0f..682f3e57a1a5 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1217,6 +1217,7 @@ async fn run_project( args.package, args.kind, args.vcs, + args.build_backend, args.no_readme, args.author_from, args.no_pin_python, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 80f274b7d0b1..0c3de8b93766 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -21,8 +21,8 @@ use uv_client::Connectivity; use uv_configuration::{ BuildOptions, Concurrency, ConfigSettings, DevMode, EditableMode, ExportFormat, ExtrasSpecification, HashCheckingMode, IndexStrategy, InstallOptions, KeyringProviderType, - NoBinary, NoBuild, PreviewMode, Reinstall, SourceStrategy, TargetTriple, TrustedHost, - TrustedPublishing, Upgrade, VersionControlSystem, + NoBinary, NoBuild, PreviewMode, ProjectBuildBackend, Reinstall, SourceStrategy, TargetTriple, + TrustedHost, TrustedPublishing, Upgrade, VersionControlSystem, }; use uv_distribution_types::{DependencyMetadata, IndexLocations}; use uv_install_wheel::linker::LinkMode; @@ -164,6 +164,7 @@ pub(crate) struct InitSettings { pub(crate) package: bool, pub(crate) kind: InitKind, pub(crate) vcs: Option, + pub(crate) build_backend: Option, pub(crate) no_readme: bool, pub(crate) author_from: Option, pub(crate) no_pin_python: bool, @@ -185,6 +186,7 @@ impl InitSettings { lib, script, vcs, + build_backend, no_readme, author_from, no_pin_python, @@ -208,6 +210,7 @@ impl InitSettings { package, kind, vcs, + build_backend, no_readme, author_from, no_pin_python, diff --git a/crates/uv/tests/it/init.rs b/crates/uv/tests/it/init.rs index 6cbba5124157..d039dc8f471b 100644 --- a/crates/uv/tests/it/init.rs +++ b/crates/uv/tests/it/init.rs @@ -2316,7 +2316,7 @@ fn init_with_author() { description = "Add your description here" readme = "README.md" authors = [ - { name = "Alice", email = "alice@example.com" } + { name = "Alice", email = "alice@example.com" } ] requires-python = ">=3.12" dependencies = [] @@ -2338,7 +2338,7 @@ fn init_with_author() { description = "Add your description here" readme = "README.md" authors = [ - { name = "Alice", email = "alice@example.com" } + { name = "Alice", email = "alice@example.com" } ] requires-python = ">=3.12" dependencies = [] @@ -2380,3 +2380,692 @@ fn init_with_author() { ); }); } + +/// Run `uv init --app --package --build-backend flit` to create a packaged application project +#[test] +fn init_application_package_flit() -> Result<()> { + let context = TestContext::new("3.12"); + + let child = context.temp_dir.child("foo"); + child.create_dir_all()?; + + let pyproject_toml = child.join("pyproject.toml"); + let init_py = child.join("src").join("foo").join("__init__.py"); + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--app").arg("--package").arg("--build-backend").arg("flit"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Initialized project `foo` + "###); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject, @r###" + [project] + name = "foo" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [project.scripts] + foo = "foo:main" + + [build-system] + requires = ["flit_core>=3.2,<4"] + build-backend = "flit_core.buildapi" + "### + ); + }); + + let init = fs_err::read_to_string(init_py)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + init, @r###" + def main() -> None: + print("Hello from foo!") + "### + ); + }); + + uv_snapshot!(context.filters(), context.run().current_dir(&child).env_remove("VIRTUAL_ENV").arg("foo"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Hello from foo! + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + foo==0.1.0 (from file://[TEMP_DIR]/foo) + "###); + + Ok(()) +} + +/// Run `uv init --lib --build-backend flit` to create an library project +#[test] +fn init_library_flit() -> Result<()> { + let context = TestContext::new("3.12"); + + let child = context.temp_dir.child("foo"); + child.create_dir_all()?; + + let pyproject_toml = child.join("pyproject.toml"); + let init_py = child.join("src").join("foo").join("__init__.py"); + let py_typed = child.join("src").join("foo").join("py.typed"); + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--lib").arg("--build-backend").arg("flit"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Initialized project `foo` + "###); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject, @r###" + [project] + name = "foo" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["flit_core>=3.2,<4"] + build-backend = "flit_core.buildapi" + "### + ); + }); + + let init = fs_err::read_to_string(init_py)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + init, @r###" + def hello() -> str: + return "Hello from foo!" + "### + ); + }); + + let py_typed = fs_err::read_to_string(py_typed)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + py_typed, @"" + ); + }); + + uv_snapshot!(context.filters(), context.run().current_dir(&child).env_remove("VIRTUAL_ENV").arg("python").arg("-c").arg("import foo; print(foo.hello())"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Hello from foo! + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + foo==0.1.0 (from file://[TEMP_DIR]/foo) + "###); + + Ok(()) +} + +/// Run `uv init --app --package --build-backend maturin` to create a packaged application project +#[test] +fn init_app_build_backend_maturin() -> Result<()> { + let context = TestContext::new("3.12"); + + let child = context.temp_dir.child("foo"); + child.create_dir_all()?; + + let pyproject_toml = child.join("pyproject.toml"); + let init_py = child.join("src").join("foo").join("__init__.py"); + let pyi_file = child.join("src").join("foo").join("_core.pyi"); + let lib_core = child.join("src").join("lib.rs"); + let build_file = child.join("Cargo.toml"); + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--app").arg("--package").arg("--build-backend").arg("maturin"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Initialized project `foo` + "###); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject, @r###" + [project] + name = "foo" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [project.scripts] + foo = "foo:main" + + [tool.maturin] + module-name = "foo._core" + python-packages = ["foo"] + python-source = "src" + + [build-system] + requires = ["maturin>=1.0,<2.0"] + build-backend = "maturin" + "### + ); + }); + + let init = fs_err::read_to_string(init_py)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + init, @r###" + from foo._core import hello_from_bin + + def main() -> None: + print(hello_from_bin()) + "### + ); + }); + + let pyi_contents = fs_err::read_to_string(pyi_file)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyi_contents, @r###" + from __future__ import annotations + + def hello_from_bin() -> str: ... + "### + ); + }); + + let lib_core_contents = fs_err::read_to_string(lib_core)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lib_core_contents, @r###" + use pyo3::prelude::*; + + #[pyfunction] + fn hello_from_bin() -> String { + return "Hello from foo!".to_string(); + } + + /// A Python module implemented in Rust. The name of this function must match + /// the `lib.name` setting in the `Cargo.toml`, else Python will not be able to + /// import the module. + #[pymodule] + fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(hello_from_bin, m)?)?; + Ok(()) + } + "### + ); + }); + + let build_file_contents = fs_err::read_to_string(build_file)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + build_file_contents, @r###" + [package] + name = "foo" + version = "0.1.0" + edition = "2021" + + [lib] + name = "_core" + # "cdylib" is necessary to produce a shared library for Python to import from. + crate-type = ["cdylib"] + + [dependencies] + # "extension-module" tells pyo3 we want to build an extension module (skips linking against libpython.so) + # "abi3-py39" tells pyo3 (and maturin) to build using the stable ABI with minimum Python version 3.9 + pyo3 = { version = "0.22.4", features = ["extension-module", "abi3-py39"] } + "### + ); + }); + + uv_snapshot!(context.filters(), context.run().current_dir(&child).env_remove("VIRTUAL_ENV").arg("foo"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Hello from foo! + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + foo==0.1.0 (from file://[TEMP_DIR]/foo) + "###); + + Ok(()) +} + +/// Run `uv init --app --package --build-backend scikit` to create a packaged application project +#[test] +fn init_app_build_backend_scikit() -> Result<()> { + let context = TestContext::new("3.12"); + + let child = context.temp_dir.child("foo"); + child.create_dir_all()?; + + let pyproject_toml = child.join("pyproject.toml"); + let init_py = child.join("src").join("foo").join("__init__.py"); + let pyi_file = child.join("src").join("foo").join("_core.pyi"); + let lib_core = child.join("src").join("main.cpp"); + let build_file = child.join("CMakeLists.txt"); + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--app").arg("--package").arg("--build-backend").arg("scikit"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Initialized project `foo` + "###); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject, @r###" + [project] + name = "foo" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [project.scripts] + foo = "foo:main" + + [tool.scikit-build] + minimum-version = "build-system.requires" + build-dir = "build/{wheel_tag}" + + [build-system] + requires = ["scikit-build-core>=0.10", "pybind11"] + build-backend = "scikit_build_core.build" + "### + ); + }); + + let init = fs_err::read_to_string(init_py)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + init, @r###" + from foo._core import hello_from_bin + + def main() -> None: + print(hello_from_bin()) + "### + ); + }); + + let pyi_contents = fs_err::read_to_string(pyi_file)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyi_contents, @r###" + from __future__ import annotations + + def hello_from_bin() -> str: ... + "### + ); + }); + + let lib_core_contents = fs_err::read_to_string(lib_core)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lib_core_contents, @r###" + #include + + std::string hello_from_bin() { return "Hello from foo!"; } + + namespace py = pybind11; + + PYBIND11_MODULE(_core, m) { + m.doc() = "pybind11 hello module"; + + m.def("hello_from_bin", &hello_from_bin, R"pbdoc( + A function that returns a Hello string. + )pbdoc"); + } + "### + ); + }); + + let build_file_contents = fs_err::read_to_string(build_file)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + build_file_contents, @r###" + cmake_minimum_required(VERSION 3.15) + project(${SKBUILD_PROJECT_NAME} LANGUAGES CXX) + + set(PYBIND11_FINDPYTHON ON) + find_package(pybind11 CONFIG REQUIRED) + + pybind11_add_module(_core MODULE src/main.cpp) + install(TARGETS _core DESTINATION ${SKBUILD_PROJECT_NAME}) + "### + ); + }); + + // We do not test with uv run since it would otherwise require specific CXX build tooling + + Ok(()) +} + +/// Run `uv init --lib --build-backend maturin` to create a packaged application project +#[test] +fn init_lib_build_backend_maturin() -> Result<()> { + let context = TestContext::new("3.12"); + + let child = context.temp_dir.child("foo"); + child.create_dir_all()?; + + let pyproject_toml = child.join("pyproject.toml"); + let init_py = child.join("src").join("foo").join("__init__.py"); + let pyi_file = child.join("src").join("foo").join("_core.pyi"); + let lib_core = child.join("src").join("lib.rs"); + let build_file = child.join("Cargo.toml"); + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--lib").arg("--build-backend").arg("maturin"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Initialized project `foo` + "###); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject, @r###" + [project] + name = "foo" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [tool.maturin] + module-name = "foo._core" + python-packages = ["foo"] + python-source = "src" + + [build-system] + requires = ["maturin>=1.0,<2.0"] + build-backend = "maturin" + "### + ); + }); + + let init = fs_err::read_to_string(init_py)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + init, @r###" + from foo._core import hello_from_bin + + def hello() -> str: + return hello_from_bin() + "### + ); + }); + + let pyi_contents = fs_err::read_to_string(pyi_file)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyi_contents, @r###" + from __future__ import annotations + + def hello_from_bin() -> str: ... + "### + ); + }); + + let lib_core_contents = fs_err::read_to_string(lib_core)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lib_core_contents, @r###" + use pyo3::prelude::*; + + #[pyfunction] + fn hello_from_bin() -> String { + return "Hello from foo!".to_string(); + } + + /// A Python module implemented in Rust. The name of this function must match + /// the `lib.name` setting in the `Cargo.toml`, else Python will not be able to + /// import the module. + #[pymodule] + fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(hello_from_bin, m)?)?; + Ok(()) + } + "### + ); + }); + + let build_file_contents = fs_err::read_to_string(build_file)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + build_file_contents, @r###" + [package] + name = "foo" + version = "0.1.0" + edition = "2021" + + [lib] + name = "_core" + # "cdylib" is necessary to produce a shared library for Python to import from. + crate-type = ["cdylib"] + + [dependencies] + # "extension-module" tells pyo3 we want to build an extension module (skips linking against libpython.so) + # "abi3-py39" tells pyo3 (and maturin) to build using the stable ABI with minimum Python version 3.9 + pyo3 = { version = "0.22.4", features = ["extension-module", "abi3-py39"] } + "### + ); + }); + + uv_snapshot!(context.filters(), context.run().current_dir(&child).env_remove("VIRTUAL_ENV").arg("python").arg("-c").arg("import foo; print(foo.hello())"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Hello from foo! + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + foo==0.1.0 (from file://[TEMP_DIR]/foo) + "###); + + Ok(()) +} + +/// Run `uv init --lib --build-backend scikit` to create a packaged application project +#[test] +fn init_lib_build_backend_scikit() -> Result<()> { + let context = TestContext::new("3.12"); + + let child = context.temp_dir.child("foo"); + child.create_dir_all()?; + + let pyproject_toml = child.join("pyproject.toml"); + let init_py = child.join("src").join("foo").join("__init__.py"); + let pyi_file = child.join("src").join("foo").join("_core.pyi"); + let lib_core = child.join("src").join("main.cpp"); + let build_file = child.join("CMakeLists.txt"); + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--lib").arg("--build-backend").arg("scikit"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Initialized project `foo` + "###); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject, @r###" + [project] + name = "foo" + version = "0.1.0" + description = "Add your description here" + readme = "README.md" + requires-python = ">=3.12" + dependencies = [] + + [tool.scikit-build] + minimum-version = "build-system.requires" + build-dir = "build/{wheel_tag}" + + [build-system] + requires = ["scikit-build-core>=0.10", "pybind11"] + build-backend = "scikit_build_core.build" + "### + ); + }); + + let init = fs_err::read_to_string(init_py)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + init, @r###" + from foo._core import hello_from_bin + + def hello() -> str: + return hello_from_bin() + "### + ); + }); + + let pyi_contents = fs_err::read_to_string(pyi_file)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyi_contents, @r###" + from __future__ import annotations + + def hello_from_bin() -> str: ... + "### + ); + }); + + let lib_core_contents = fs_err::read_to_string(lib_core)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lib_core_contents, @r###" + #include + + std::string hello_from_bin() { return "Hello from foo!"; } + + namespace py = pybind11; + + PYBIND11_MODULE(_core, m) { + m.doc() = "pybind11 hello module"; + + m.def("hello_from_bin", &hello_from_bin, R"pbdoc( + A function that returns a Hello string. + )pbdoc"); + } + "### + ); + }); + + let build_file_contents = fs_err::read_to_string(build_file)?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + build_file_contents, @r###" + cmake_minimum_required(VERSION 3.15) + project(${SKBUILD_PROJECT_NAME} LANGUAGES CXX) + + set(PYBIND11_FINDPYTHON ON) + find_package(pybind11 CONFIG REQUIRED) + + pybind11_add_module(_core MODULE src/main.cpp) + install(TARGETS _core DESTINATION ${SKBUILD_PROJECT_NAME}) + "### + ); + }); + + // We do not test with uv run since it would otherwise require specific CXX build tooling + + Ok(()) +} diff --git a/docs/concepts/projects.md b/docs/concepts/projects.md index 6dc5cbba7fc1..d4aa0766f805 100644 --- a/docs/concepts/projects.md +++ b/docs/concepts/projects.md @@ -203,10 +203,41 @@ def hello() -> str: And you can import and execute it using `uv run`: ```console -$ uv run python -c "import example_lib; print(example_lib.hello())" +$ uv run --directory example-lib python -c "import example_lib; print(example_lib.hello())" Hello from example-lib! ``` +You can select a different build backend template by using `--build-backend` with `hatchling`, +`flit-core`, `pdm-backend`, `setuptools`, `maturin`, or `scikit-build-core`. + +```console +$ uv init --lib --build-backend maturin example-lib +$ tree example-lib +example-lib +├── .python-version +├── Cargo.toml +├── README.md +├── pyproject.toml +└── src + ├── lib.rs + └── example_lib + ├── py.typed + ├── __init__.py + └── _core.pyi +``` + +And you can import and execute it using `uv run`: + +```console +$ uv run --directory example-lib python -c "import example_lib; print(example_lib.hello())" +Hello from example-lib! +``` + +!!! tip + +Changes to `lib.rs` or `main.cpp` will require running `--reinstall` when using binary build +backends such as `maturin` and `scikit-build-core`. + ### Packaged applications The `--package` flag can be passed to `uv init` to create a distributable application, e.g., if you @@ -257,7 +288,7 @@ build-backend = "hatchling.build" Which can be executed with `uv run`: ```console -$ uv run example-packaged-app +$ uv run --directory example-packaged-app example-packaged-app Hello from example-packaged-app! ``` @@ -267,6 +298,31 @@ Hello from example-packaged-app! However, this may require changes to the project directory structure, depending on the build backend. +In addition, you can further customize the build backend of a packaged application by specifying +`--build-backend` including binary build backends such as `maturin`. + +```console +$ uv init --app --package --build-backend maturin example-packaged-app +$ tree example-packaged-app +example-packaged-app +├── .python-version +├── Cargo.toml +├── README.md +├── pyproject.toml +└── src + ├── lib.rs + └── example_packaged_app + ├── __init__.py + └── _core.pyi +``` + +Which can also be executed with `uv run`: + +```console +$ uv run --directory example-packaged-app example-packaged-app +Hello from example-packaged-app! +``` + ## Project environments When working on a project with uv, uv will create a virtual environment as needed. While some uv diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 5add2f5ddb46..f88e1d132f17 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -453,6 +453,23 @@ uv init [OPTIONS] [PATH]
  • none: Do not infer the author information
  • +
    --build-backend build-backend

    Initialize a build-backend of choice for the project

    + +

    Possible values:

    + +
      +
    • hatch: Use hatchling as the project build backend
    • + +
    • flit: Use flit-core as the project build backend
    • + +
    • pdm: Use pdm-backend as the project build backend
    • + +
    • setuptools: Use setuptools as the project build backend
    • + +
    • maturin: Use maturin as the project build backend
    • + +
    • scikit: Use scikit-build-core as the project build backend
    • +
    --cache-dir cache-dir

    Path to the cache directory.

    Defaults to $HOME/Library/Caches/uv on macOS, $XDG_CACHE_HOME/uv or $HOME/.cache/uv on Linux, and %LOCALAPPDATA%\uv\cache on Windows.