From 4bc6a09a1e1a7918cf07ac4ee9c480cdad84df8d Mon Sep 17 00:00:00 2001 From: samypr100 <3933065+samypr100@users.noreply.github.com> Date: Tue, 1 Oct 2024 21:34:41 -0400 Subject: [PATCH] feat(init): support different init build backends --- crates/uv-cli/src/lib.rs | 9 +- crates/uv-configuration/src/lib.rs | 2 + .../src/project_build_backend.rs | 22 + crates/uv/src/commands/project/init.rs | 319 +++++- crates/uv/src/lib.rs | 1 + crates/uv/src/settings.rs | 7 +- crates/uv/tests/init.rs | 958 ++++++++++++++++++ docs/concepts/projects.md | 55 +- docs/reference/cli.md | 21 + 9 files changed, 1361 insertions(+), 33 deletions(-) create mode 100644 crates/uv-configuration/src/project_build_backend.rs diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index c9e92ded31573..8bf27cb9fa3e3 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}; @@ -2396,6 +2396,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 b4a5ed0ee9acf..69c6e72b7e353 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -7,7 +7,7 @@ use owo_colors::OwoColorize; use tracing::{debug, warn}; use uv_cache::Cache; 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; @@ -35,6 +35,7 @@ pub(crate) async fn init( package: bool, init_kind: InitKind, vcs: Option, + build_backend: Option, no_readme: bool, no_pin_python: bool, python: Option, @@ -110,6 +111,7 @@ pub(crate) async fn init( package, project_kind, vcs, + build_backend, no_readme, no_pin_python, python, @@ -236,6 +238,7 @@ async fn init_project( package: bool, project_kind: InitProjectKind, vcs: Option, + build_backend: Option, no_readme: bool, no_pin_python: bool, python: Option, @@ -461,6 +464,7 @@ async fn init_project( &requires_python, python_request.as_ref(), vcs, + build_backend, no_readme, package, ) @@ -550,6 +554,7 @@ impl InitProjectKind { requires_python: &RequiresPython, python_request: Option<&PythonRequest>, vcs: Option, + build_backend: Option, no_readme: bool, package: bool, ) -> Result<()> { @@ -561,6 +566,7 @@ impl InitProjectKind { requires_python, python_request, vcs, + build_backend, no_readme, package, ) @@ -573,6 +579,7 @@ impl InitProjectKind { requires_python, python_request, vcs, + build_backend, no_readme, package, ) @@ -589,9 +596,12 @@ impl InitProjectKind { requires_python: &RequiresPython, python_request: Option<&PythonRequest>, vcs: Option, + build_backend: Option, no_readme: bool, package: bool, ) -> Result<()> { + fs_err::create_dir_all(path)?; + // Create the `pyproject.toml` let mut pyproject = pyproject_project(name, requires_python, no_readme); @@ -602,26 +612,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)?; } - fs_err::create_dir_all(path)?; - // 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 @@ -670,36 +678,33 @@ impl InitProjectKind { requires_python: &RequiresPython, python_request: Option<&PythonRequest>, vcs: Option, + build_backend: Option, no_readme: bool, package: bool, ) -> Result<()> { if !package { return Err(anyhow!("Library projects must be packaged")); } + fs_err::create_dir_all(path)?; // Create the `pyproject.toml` let mut pyproject = pyproject_project(name, requires_python, 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::create_dir_all(path)?; 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 @@ -748,12 +753,63 @@ fn pyproject_project( } /// 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`. @@ -765,6 +821,213 @@ 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', + 'python.install_env=venv', + ], + ) + + 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 7eb87fcae77c3..04ae5ab442e91 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1191,6 +1191,7 @@ async fn run_project( args.package, args.kind, args.vcs, + args.build_backend, args.no_readme, args.no_pin_python, args.python, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 55a372a45385e..17b5ac81a363b 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; @@ -163,6 +163,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) no_pin_python: bool, pub(crate) no_workspace: bool, @@ -183,6 +184,7 @@ impl InitSettings { lib, script, vcs, + build_backend, no_readme, no_pin_python, no_workspace, @@ -205,6 +207,7 @@ impl InitSettings { package, kind, vcs, + build_backend, no_readme, no_pin_python, no_workspace, diff --git a/crates/uv/tests/init.rs b/crates/uv/tests/init.rs index c735eeab693c3..99099567d0e92 100644 --- a/crates/uv/tests/init.rs +++ b/crates/uv/tests/init.rs @@ -2252,3 +2252,961 @@ fn init_git_not_installed() { error: Attempted to initialize a Git repository, but `git` was not found in PATH "###); } + +/// 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', + 'python.install_env=venv', + ], + ) + + 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', + 'python.install_env=venv', + ], + ) + + 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 b46513fce79f0..83953f242a2f6 100644 --- a/docs/concepts/projects.md +++ b/docs/concepts/projects.md @@ -203,7 +203,33 @@ 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! ``` @@ -257,7 +283,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 +293,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 uv creates a virtual environment in a `.venv` directory next to the `pyproject.toml`. This virtual diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4b0c56f3071c9..d314133a69e3e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -435,6 +435,27 @@ uv init [OPTIONS] [PATH]

By default, an application is not intended to be built and distributed as a Python package. The --package option can be used to create an application that is distributable, e.g., if you want to distribute a command-line interface via PyPI.

+
--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.