diff --git a/README.md b/README.md index 4ed41e3..4ac2df8 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,20 @@ destination = "project/lib/{shared_library}" Destination templates support `{module}`, `{target}`, `{profile}`, `{name}`, `{shared_library}`, `{import_library}`, and `{python_extension_name}`. +### Using the Rust debug profile for editable installs + +By default rust release profile is used to compile the artifacts and it can +be changed using the `profile` option in `tool.hatch.build.hooks.hatch-rs` +or `tool.hatch.build.hooks.hatch-rs.artifacts` sections. + +When installing the Python project in development mode (aka editable installs) +using the command `pip install --editable `, it can be preferred +to use the rust debug profile so the compilation time is faster. + +Using the Rust debug profile for editable installs can be enforced by setting the +`editable-debug` option to `true` either in `tool.hatch.build.hooks.hatch-rs` +or `tool.hatch.build.hooks.hatch-rs.artifacts` sections. + ### Generated files and headers Artifacts with `command` run an argv-list command and then validate explicit diff --git a/hatch_rs/plugin.py b/hatch_rs/plugin.py index 052803a..4aa1e32 100644 --- a/hatch_rs/plugin.py +++ b/hatch_rs/plugin.py @@ -50,6 +50,8 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None: build_plan = build_plan_class(**config.model_dump()) # Generate commands + if isinstance(build_plan, HatchRustBuildPlan): + build_plan.set_editable_install(version == "editable") build_plan.generate() # Log commands if in verbose mode diff --git a/hatch_rs/structs.py b/hatch_rs/structs.py index 86ae165..6e2508a 100644 --- a/hatch_rs/structs.py +++ b/hatch_rs/structs.py @@ -416,6 +416,11 @@ class RustArtifactConfig(BaseModel): manifest: Path | None = Field(default=None, description="Path to Cargo.toml, relative to the hook path unless absolute.") build_type: BuildType | None = Field(default=None, alias="build-type") profile: str | None = Field(default=None, description="Cargo profile for this artifact.") + editable_debug: bool = Field( + default=False, + alias="editable-debug", + description="Use the Rust debug profile for editable installs.", + ) target: str | None = Field(default=None, description="Rust target triple for this artifact.") package: str | None = Field(default=None, description="Cargo package selector.") cargo_target_kind: CargoTargetKind | None = Field(default=None, alias="cargo-target-kind", description="Cargo target selector kind.") @@ -576,6 +581,11 @@ class HatchRustBuildConfig(BaseModel): manifest: Path | None = Field(default=None, description="Path to Cargo.toml, relative to path unless absolute.") build_type: BuildType = Field(default="release", alias="build-type") profile: str | None = Field(default=None, description="Cargo profile to build. Overrides build_type when set.") + editable_debug: bool = Field( + default=False, + alias="editable-debug", + description="Use the Rust debug profile for editable installs.", + ) features: list[str] = Field(default_factory=list, description="Cargo features to enable.") all_features: bool = Field(default=False, alias="all-features", description="Enable all Cargo features.") no_default_features: bool = Field(default=False, alias="no-default-features", description="Disable Cargo default features.") @@ -657,6 +667,7 @@ class HatchRustBuildPlan(HatchRustBuildConfig): _target_dir: Path | None = PrivateAttr(default=None) _set_target_dir_env: bool = PrivateAttr(default=False) _temporary_target_dir: TemporaryDirectory[str] | None = PrivateAttr(default=None) + _editable_install: bool = PrivateAttr(default=False) @property def libraries(self) -> list[str]: @@ -722,6 +733,8 @@ def _artifact_manifest(self, artifact: RustArtifactConfig) -> Path | None: return artifact.manifest if artifact.manifest is not None else self.manifest def _artifact_profile(self, artifact: RustArtifactConfig) -> str: + if self._editable_install and (artifact.editable_debug or self.editable_debug): + return "debug" build_type = artifact.build_type or self.build_type return artifact.profile or self.profile or build_type @@ -897,6 +910,9 @@ def _build_command_artifact_plan(self, artifact: RustArtifactConfig, *, global_t set_target_dir_env=set_target_dir_env, ) + def set_editable_install(self, editable_install: bool) -> None: + self._editable_install = editable_install + def generate(self): if self._temporary_target_dir is not None: self._temporary_target_dir.cleanup() @@ -910,7 +926,6 @@ def generate(self): self._shared_scripts = {} self._artifact_manifest_records = [] self._libraries = [] - global_target = self.target for artifact in self._configured_artifacts(): if self._is_generated_artifact(artifact): diff --git a/hatch_rs/tests/test_plugin.py b/hatch_rs/tests/test_plugin.py new file mode 100644 index 0000000..acd9251 --- /dev/null +++ b/hatch_rs/tests/test_plugin.py @@ -0,0 +1,40 @@ +from hatch_rs.plugin import HatchRustBuildHook + + +def test_custom_build_plan_generate_keeps_no_argument_contract(monkeypatch, tmp_path): + generated = [] + + class CustomBuildPlan: + def __init__(self, **_config): + self.commands = [] + self.copied_artifacts = [] + self.shared_data = {"source": "destination"} + self.shared_scripts = {} + self.libraries = [] + + def generate(self): + generated.append(True) + + def execute(self): + pass + + def cleanup(self): + pass + + class Metadata: + def __init__(self): + self.config = {"project": {"name": "project"}} + + monkeypatch.setattr("hatch_rs.plugin.import_string", lambda path: CustomBuildPlan) + hook = HatchRustBuildHook( + root=str(tmp_path), + config={"module": "project", "path": str(tmp_path), "build-plan-class": "tests.CustomBuildPlan"}, + build_config=None, + metadata=Metadata(), + directory=str(tmp_path), + target_name="wheel", + ) + + hook.initialize("standard", {}) + + assert generated == [True] diff --git a/hatch_rs/tests/test_project_editable_debug/Cargo.lock b/hatch_rs/tests/test_project_editable_debug/Cargo.lock new file mode 100644 index 0000000..175c2c2 --- /dev/null +++ b/hatch_rs/tests/test_project_editable_debug/Cargo.lock @@ -0,0 +1,180 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + +[[package]] +name = "inventory" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab08d7cd2c5897f2c949e5383ea7c7db03fb19130ffcfbf7eda795137ae3cb83" +dependencies = [ + "rustversion", +] + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "project_py" +version = "0.1.0" +dependencies = [ + "pyo3", +] + +[[package]] +name = "pyo3" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8970a78afe0628a3e3430376fc5fd76b6b45c4d43360ffd6cdd40bdde72b682a" +dependencies = [ + "indoc", + "inventory", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458eb0c55e7ece017adeba38f2248ff3ac615e53660d7c71a238d7d2a01c7598" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7114fe5457c61b276ab77c5055f206295b812608083644a5c5b2640c3102565c" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8725c0a622b374d6cb051d11a0983786448f7785336139c3c94f5aa6bef7e50" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4109984c22491085343c05b0dbc54ddc405c3cf7b4374fc533f5c3313a572ccc" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" diff --git a/hatch_rs/tests/test_project_editable_debug/Cargo.toml b/hatch_rs/tests/test_project_editable_debug/Cargo.toml new file mode 100644 index 0000000..8fb6b75 --- /dev/null +++ b/hatch_rs/tests/test_project_editable_debug/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "project_py" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "extension" +path = "src/lib.rs" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.25", features = ["abi3-py39", "extension-module", "multiple-pymethods"] } + +[profile.release] +panic = 'abort' +lto = true diff --git a/hatch_rs/tests/test_project_editable_debug/project/__init__.py b/hatch_rs/tests/test_project_editable_debug/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hatch_rs/tests/test_project_editable_debug/pyproject.toml b/hatch_rs/tests/test_project_editable_debug/pyproject.toml new file mode 100644 index 0000000..f493531 --- /dev/null +++ b/hatch_rs/tests/test_project_editable_debug/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["hatchling>=1.20"] +build-backend = "hatchling.build" + +[project] +name = "hatch-rs-test-project-editable-debug" +description = "Basic test project for hatch-rs" +version = "0.1.0" +requires-python = ">=3.9" + +[tool.hatch.build] +artifacts = [ + "project/*.dll", + "project/*.dylib", + "project/*.so", +] + +[tool.hatch.build.sources] +src = "/" + +[tool.hatch.build.targets.sdist] +packages = ["project"] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.hatch.build.hooks.hatch-rs] +verbose = true +abi3 = true +module = "project" +target-dir = "target" + +[[tool.hatch.build.hooks.hatch-rs.artifacts]] +name = "extension" +manifest = "Cargo.toml" +destination = "project/{python_extension_name}" +editable-debug = true diff --git a/hatch_rs/tests/test_project_editable_debug/src/lib.rs b/hatch_rs/tests/test_project_editable_debug/src/lib.rs new file mode 100644 index 0000000..66338f0 --- /dev/null +++ b/hatch_rs/tests/test_project_editable_debug/src/lib.rs @@ -0,0 +1,13 @@ +use pyo3::prelude::*; + +#[pyfunction] +pub fn hello() -> &'static str { + "A string" +} + +#[pymodule] +fn extension(_py: Python, m: &Bound) -> PyResult<()> { + // Example + m.add_function(pyo3::wrap_pyfunction!(hello, m)?)?; + Ok(()) +} diff --git a/hatch_rs/tests/test_projects.py b/hatch_rs/tests/test_projects.py index 44ace4a..f9ddb42 100644 --- a/hatch_rs/tests/test_projects.py +++ b/hatch_rs/tests/test_projects.py @@ -306,3 +306,38 @@ def test_c_abi_symbol_validation_failure(self): assert completed.returncode != 0 assert "validation_failure_missing_symbol" in completed.stdout + + def test_editable_debug(self): + project_folder = "test_project_editable_debug" + # cleanup + rmtree(f"hatch_rs/tests/{project_folder}/target", ignore_errors=True) + rmtree(f"hatch_rs/tests/{project_folder}/project/extension.abi3.so", ignore_errors=True) + rmtree(f"hatch_rs/tests/{project_folder}/project/extension.abi3.pyd", ignore_errors=True) + modules.pop("project", None) + modules.pop("project.extension", None) + + # install project in development mode (editable install) + check_call( + [ + "pip", + "install", + "--verbose", + # ensure hatch-rs from this repository is used and not fetched from PyPI + "--no-build-isolation", + "--editable", + ".", + ], + cwd=f"hatch_rs/tests/{project_folder}", + env=_subprocess_env(), + ) + + # check editable-debug option was honored + assert Path(f"hatch_rs/tests/{project_folder}/target/debug").exists() + assert not Path(f"hatch_rs/tests/{project_folder}/target/release").exists() + + # import + here = Path(__file__).parent / project_folder + path.insert(0, str(here)) + import project.extension + + assert project.extension.hello() == "A string" diff --git a/hatch_rs/tests/test_structs.py b/hatch_rs/tests/test_structs.py index 9e8e55d..7d34bc5 100644 --- a/hatch_rs/tests/test_structs.py +++ b/hatch_rs/tests/test_structs.py @@ -123,6 +123,31 @@ def test_build_plan_generates_cargo_invocation(tmp_path): ] +def test_build_plan_uses_debug_profile_for_editable_install(tmp_path): + plan = HatchRustBuildPlan( + module="project", + path=tmp_path, + target="x86_64-unknown-linux-gnu", + profile="optimized", + editable_debug=True, + ) + plan.set_editable_install(True) + + assert plan.generate() == ["cargo rustc --target x86_64-unknown-linux-gnu -- --crate-type cdylib"] + + +def test_build_plan_preserves_profile_for_standard_install(tmp_path): + plan = HatchRustBuildPlan( + module="project", + path=tmp_path, + target="x86_64-unknown-linux-gnu", + profile="optimized", + editable_debug=True, + ) + + assert plan.generate() == ["cargo rustc --profile optimized --target x86_64-unknown-linux-gnu -- --crate-type cdylib"] + + def test_build_plan_generates_manifest_and_cargo_options(tmp_path): plan = HatchRustBuildPlan( module="project", diff --git a/pyproject.toml b/pyproject.toml index fffb15b..c1745f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,9 +45,11 @@ develop = [ "bump-my-version", "check-dist", "codespell", + "editables", "hatchling", "mdformat", "mdformat-tables>=1", + "pip", "pytest", "pytest-cov", "ruff",