diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 8cdbde8322037..d41e52f67e385 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,13 @@ pub struct InitArgs { #[arg(long, value_enum, conflicts_with = "script")] pub vcs: Option, + /// Initialize a build-backend of choice for the project. + /// + /// By default, uv will use (`hatchling`). Use `--build-backend` to specify an + /// alternative build backend. + #[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 fbd40a32beb84..90cdfb0c6ae50 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 0000000000000..4dbbe2c9795ab --- /dev/null +++ b/crates/uv-configuration/src/project_build_backend.rs @@ -0,0 +1,22 @@ +/// 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, + /// Use [meson-python](https://pypi.org/project/meson-python) as the project build backend. + Meson, +} diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 3f933a3d0bf4e..ad5f6b44c58c9 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_pep440::Version; use uv_pep508::PackageName; @@ -37,6 +37,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, @@ -114,6 +115,7 @@ pub(crate) async fn init( package, project_kind, vcs, + build_backend, no_readme, author_from, no_pin_python, @@ -245,6 +247,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, @@ -485,6 +488,7 @@ async fn init_project( &requires_python, python_request.as_ref(), vcs, + build_backend, author_from, no_readme, package, @@ -575,6 +579,7 @@ impl InitProjectKind { requires_python: &RequiresPython, python_request: Option<&PythonRequest>, vcs: Option, + build_backend: Option, author_from: Option, no_readme: bool, package: bool, @@ -587,6 +592,7 @@ impl InitProjectKind { requires_python, python_request, vcs, + build_backend, author_from, no_readme, package, @@ -600,6 +606,7 @@ impl InitProjectKind { requires_python, python_request, vcs, + build_backend, author_from, no_readme, package, @@ -617,6 +624,7 @@ impl InitProjectKind { requires_python: &RequiresPython, python_request: Option<&PythonRequest>, vcs: Option, + build_backend: Option, author_from: Option, no_readme: bool, package: bool, @@ -643,24 +651,24 @@ 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 { + // Retrieve build backend + let build_backend = build_backend.unwrap_or_default(); + // 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"); + let packaged_script = generate_package_script(name, path, build_backend, false)?; 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}!") - "#}, - )?; + fs_err::write(init_py, packaged_script)?; } } else { // Create `hello.py` if it doesn't exist @@ -709,6 +717,7 @@ impl InitProjectKind { requires_python: &RequiresPython, python_request: Option<&PythonRequest>, vcs: Option, + build_backend: Option, author_from: Option, no_readme: bool, package: bool, @@ -725,24 +734,20 @@ 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"); + let packaged_script = generate_package_script(name, path, build_backend, true)?; if !init_py.try_exists()? { - fs_err::write( - init_py, - indoc::formatdoc! {r#" - def hello() -> str: - return "Hello from {name}!" - "#}, - )?; + fs_err::write(init_py, packaged_script)?; } // Create a `py.typed` file @@ -806,18 +811,69 @@ 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(), + ProjectBuildBackend::Meson => indoc::indoc! {r#" + [build-system] + requires = ["meson-python", "pybind11"] + build-backend = "mesonpy" + "#} + .to_string(), + } } /// Generate the `[project.scripts]` section of a `pyproject.toml`. @@ -829,6 +885,212 @@ 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-py38" tells pyo3 (and maturin) to build using the stable ABI with minimum Python version 3.8 + pyo3 = {{ version = "0.22.3", features = ["extension-module", "abi3-py38"] }} + "#}, + )?; + } + } + 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}}) + "#}, + )?; + } + } + ProjectBuildBackend::Meson => { + // Generate meson.build + let build_file = path.join("meson.build"); + if !build_file.try_exists()? { + fs_err::write( + build_file, + indoc::formatdoc! {r#" + project( + '{module_name}', + 'cpp', + version: '0.1.0', + meson_version: '>= 1.2.3', + default_options: [ + 'cpp_std=c++11', + ], + ) + + py = import('python').find_installation(pure: false) + pybind11_dep = dependency('pybind11') + + py.extension_module('_core', + 'src/main.cpp', + subdir: '{module_name}', + install: true, + dependencies : [pybind11_dep], + ) + + install_subdir('src/{module_name}', install_dir: py.get_install_dir() / '{module_name}', strip_directory: true) + "#}, + )?; + } + } + _ => {} + } + Ok(()) +} + +/// Generate startup scripts for a package-based application or library. +fn generate_package_script( + package: &PackageName, + path: &Path, + build_backend: ProjectBuildBackend, + is_lib: bool, +) -> Result { + let module_name = package.as_dist_info_name(); + + // 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 lib_rs = path.join("src").join("lib.rs"); + if !lib_rs.try_exists()? { + fs_err::write( + lib_rs, + 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 = path.join("src").join(&*module_name).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 | ProjectBuildBackend::Meson => { + // Generate main.cpp + let lib_rs = path.join("src").join("main.cpp"); + if !lib_rs.try_exists()? { + fs_err::write( + lib_rs, + 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 = path.join("src").join(&*module_name).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, + }; + + Ok(package_script) +} + /// 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 3c7a1d53cc238..9ee1e5191a003 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 80f274b7d0b19..0c3de8b93766d 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 6cbba51241570..d6299c728349b 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,959 @@ 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).arg("foo"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Hello from foo! + + ----- stderr ----- + warning: `VIRTUAL_ENV=[VENV]/` does not match the project environment path `.venv` and will be ignored + 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).arg("python").arg("-c").arg("import foo; print(foo.hello())"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Hello from foo! + + ----- stderr ----- + warning: `VIRTUAL_ENV=[VENV]/` does not match the project environment path `.venv` and will be ignored + 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-py38" tells pyo3 (and maturin) to build using the stable ABI with minimum Python version 3.8 + pyo3 = { version = "0.22.3", features = ["extension-module", "abi3-py38"] } + "### + ); + }); + + uv_snapshot!(context.filters(), context.run().current_dir(&child).arg("foo"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Hello from foo! + + ----- stderr ----- + warning: `VIRTUAL_ENV=[VENV]/` does not match the project environment path `.venv` and will be ignored + 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 --app --package --build-backend meson` to create a packaged application project +#[test] +fn init_app_build_backend_meson() -> 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("meson.build"); + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--app").arg("--package").arg("--build-backend").arg("meson"), @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 = ["meson-python", "pybind11"] + build-backend = "mesonpy" + "### + ); + }); + + 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###" + project( + 'foo', + 'cpp', + version: '0.1.0', + meson_version: '>= 1.2.3', + default_options: [ + 'cpp_std=c++11', + ], + ) + + py = import('python').find_installation(pure: false) + pybind11_dep = dependency('pybind11') + + py.extension_module('_core', + 'src/main.cpp', + subdir: 'foo', + install: true, + dependencies : [pybind11_dep], + ) + + install_subdir('src/foo', install_dir: py.get_install_dir() / 'foo', strip_directory: true) + "### + ); + }); + + // 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-py38" tells pyo3 (and maturin) to build using the stable ABI with minimum Python version 3.8 + pyo3 = { version = "0.22.3", features = ["extension-module", "abi3-py38"] } + "### + ); + }); + + uv_snapshot!(context.filters(), context.run().current_dir(&child).arg("python").arg("-c").arg("import foo; print(foo.hello())"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Hello from foo! + + ----- stderr ----- + warning: `VIRTUAL_ENV=[VENV]/` does not match the project environment path `.venv` and will be ignored + 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(()) +} + +/// Run `uv init --lib --build-backend meson` to create a packaged application project +#[test] +fn init_lib_build_backend_meson() -> 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("meson.build"); + + uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--lib").arg("--build-backend").arg("meson"), @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 = ["meson-python", "pybind11"] + build-backend = "mesonpy" + "### + ); + }); + + 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###" + project( + 'foo', + 'cpp', + version: '0.1.0', + meson_version: '>= 1.2.3', + default_options: [ + 'cpp_std=c++11', + ], + ) + + py = import('python').find_installation(pure: false) + pybind11_dep = dependency('pybind11') + + py.extension_module('_core', + 'src/main.cpp', + subdir: 'foo', + install: true, + dependencies : [pybind11_dep], + ) + + install_subdir('src/foo', install_dir: py.get_install_dir() / 'foo', strip_directory: true) + "### + ); + }); + + // 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 6dc5cbba7fc1a..83602a6f8f154 100644 --- a/docs/concepts/projects.md +++ b/docs/concepts/projects.md @@ -203,10 +203,59 @@ 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! ``` +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 --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! +``` + +Other build backends supported by `uv init` include `hatchling`, `flit-core`, `pdm-backend`, +`setuptools`, `maturin`, `scikit-build-core`, and `meson-python`. + +!!! tip + +Changes to `lib.rs` or `main.cpp` will require running `--reinstall` when using binary build +backends such as `maturin`, `scikit-build-core`, or `meson-python`. + +!!! note + +[meson-editable]: + https://mesonbuild.com/meson-python/how-to-guides/editable-installs.html#build-dependencies + +When `meson-python` is used with editable installations, it may be beneficial to leverage +`--no-build-isolation` such that changes to `main.cpp` can be automatically rebuilt on import. See +more on this topic in [Meson Editable Installs][meson-editable]. You may also further customize +build isolation behavior using `dependency-metadata` as described in the dedicated +[build isolation](#build-isolation) section. + +As of time of writing, `meson-python` cannot detect a moving build environment. As a result, +commands such as `uv sync` or `uv run` will not behave as expected. In such cases, it is recommended +to leverage `--no-editable` instead to avoid errors. + ### Packaged applications The `--package` flag can be passed to `uv init` to create a distributable application, e.g., if you @@ -257,7 +306,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 +316,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 5add2f5ddb46e..6aa86a7c8fd49 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -453,6 +453,27 @@ uv init [OPTIONS] [PATH]
  • none: Do not infer the author information
  • +
    --build-backend build-backend

    Initialize a build-backend of choice for the project.

    + +

    By default, uv will use (hatchling). Use --build-backend to specify an alternative build backend.

    + +

    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
    • + +
    • meson: Use meson-python 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.