Skip to content

Commit 96b66ac

Browse files
mbwaymessense
andauthored
Isolated import hook changes (#1958)
* small fixes to test-crates * added pyo3-mixed-with-path-dep test crate * moved fixing of direct_url.json into maturin itself. Also refactored develop.rs * renamed document to match title * small fixes * support windows style paths when fixing direct_url.json * fixes to test crate * updated guide * updated lockfile * updated test crate lock files * removed lock file that was supposed to be missing * updated link in SUMMARY.md * added tests for pyo3-mixed-with-path-dep --------- Co-authored-by: messense <[email protected]>
1 parent 58dec47 commit 96b66ac

File tree

17 files changed

+715
-93
lines changed

17 files changed

+715
-93
lines changed

Cargo.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ normpath = "1.1.1"
8282
path-slash = "0.2.1"
8383
pep440_rs = { version = "0.5.0", features = ["serde", "tracing"] }
8484
pep508_rs = { version = "0.4.2", features = ["serde", "tracing"] }
85-
time = "0.3.34"
85+
time = "0.3.17"
86+
url = "2.5.0"
8687
unicode-xid = { version = "0.2.4", optional = true }
8788

8889
# cli
@@ -127,8 +128,7 @@ rustls-pemfile = { version = "2.1.0", optional = true }
127128
keyring = { version = "2.3.2", default-features = false, features = [
128129
"linux-no-secret-service",
129130
], optional = true }
130-
wild = { version = "2.2.1", optional = true }
131-
url = { version = "2.3.0", optional = true }
131+
wild = { version = "2.1.0", optional = true }
132132

133133
[dev-dependencies]
134134
expect-test = "1.4.1"
@@ -154,10 +154,10 @@ upload = [
154154
"configparser",
155155
"bytesize",
156156
"dialoguer/password",
157-
"url",
158157
"wild",
159158
"dep:dirs",
160159
]
160+
161161
# keyring doesn't support *BSD so it's not enabled in `full` by default
162162
password-storage = ["upload", "keyring"]
163163

guide/src/SUMMARY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
- [Python Metadata](./metadata.md)
1212
- [Configuration](./config.md)
1313
- [Environment Variables](./environment-variables.md)
14-
- [Local Development](./develop.md)
14+
- [Local Development](./local_development.md)
1515
- [Distribution](./distribution.md)
1616
- [Sphinx Integration](./sphinx.md)
1717

guide/src/develop.md renamed to guide/src/local_development.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,15 +117,26 @@ requires = ["maturin>=1.0,<2.0"]
117117
build-backend = "maturin"
118118
```
119119

120-
Editable installs right now is only useful in mixed Rust/Python project so you
121-
don't have to recompile and reinstall when only Python source code changes. For
122-
example when using pip you can make an editable installation with
120+
Editable installs can be used with mixed Rust/Python projects so you
121+
don't have to recompile and reinstall when only Python source code changes.
122+
They can also be used with mixed and pure projects together with the
123+
import hook so that recompilation/re-installation occurs automatically
124+
when Python or Rust source code changes.
125+
126+
To install a package in editable mode with pip:
123127

124128
```bash
129+
cd my-project
125130
pip install -e .
126131
```
132+
or
133+
```bash
134+
cd my-project
135+
maturin develop
136+
```
127137

128-
Then Python source code changes will take effect immediately.
138+
Then Python source code changes will take effect immediately because the interpreter looks
139+
for the modules directly in the project source tree.
129140

130141
## Import Hook
131142

src/develop.rs

Lines changed: 225 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
use crate::build_options::CargoOptions;
22
use crate::target::Arch;
3+
use crate::BuildContext;
34
use crate::BuildOptions;
45
use crate::PlatformTag;
56
use crate::PythonInterpreter;
67
use crate::Target;
78
use anyhow::{anyhow, bail, Context, Result};
89
use cargo_options::heading;
910
use pep508_rs::{MarkerExpression, MarkerOperator, MarkerTree, MarkerValue};
11+
use regex::Regex;
12+
use std::fs;
1013
use std::path::Path;
1114
use std::path::PathBuf;
1215
use std::process::Command;
1316
use tempfile::TempDir;
17+
use url::Url;
1418

1519
/// Install the crate as module in the current virtualenv
1620
#[derive(Debug, clap::Parser)]
@@ -72,6 +76,143 @@ fn make_pip_command(python_path: &Path, pip_path: Option<&Path>) -> Command {
7276
}
7377
}
7478

79+
fn install_dependencies(
80+
build_context: &BuildContext,
81+
extras: &[String],
82+
interpreter: &PythonInterpreter,
83+
pip_path: Option<&Path>,
84+
) -> Result<()> {
85+
if !build_context.metadata21.requires_dist.is_empty() {
86+
let mut args = vec!["install".to_string()];
87+
args.extend(build_context.metadata21.requires_dist.iter().map(|x| {
88+
let mut pkg = x.clone();
89+
// Remove extra marker to make it installable with pip
90+
// Keep in sync with `Metadata21::merge_pyproject_toml()`!
91+
for extra in extras {
92+
pkg.marker = pkg.marker.and_then(|marker| -> Option<MarkerTree> {
93+
match marker.clone() {
94+
MarkerTree::Expression(MarkerExpression {
95+
l_value: MarkerValue::Extra,
96+
operator: MarkerOperator::Equal,
97+
r_value: MarkerValue::QuotedString(extra_value),
98+
}) if &extra_value == extra => None,
99+
MarkerTree::And(and) => match &*and {
100+
[existing, MarkerTree::Expression(MarkerExpression {
101+
l_value: MarkerValue::Extra,
102+
operator: MarkerOperator::Equal,
103+
r_value: MarkerValue::QuotedString(extra_value),
104+
})] if extra_value == extra => Some(existing.clone()),
105+
_ => Some(marker),
106+
},
107+
_ => Some(marker),
108+
}
109+
});
110+
}
111+
pkg.to_string()
112+
}));
113+
let status = make_pip_command(&interpreter.executable, pip_path)
114+
.args(&args)
115+
.status()
116+
.context("Failed to run pip install")?;
117+
if !status.success() {
118+
bail!(r#"pip install finished with "{}""#, status)
119+
}
120+
}
121+
Ok(())
122+
}
123+
124+
fn pip_install_wheel(
125+
build_context: &BuildContext,
126+
python: &Path,
127+
venv_dir: &Path,
128+
pip_path: Option<&Path>,
129+
wheel_filename: &Path,
130+
) -> Result<()> {
131+
let mut pip_cmd = make_pip_command(python, pip_path);
132+
let output = pip_cmd
133+
.args(["install", "--no-deps", "--force-reinstall"])
134+
.arg(dunce::simplified(wheel_filename))
135+
.output()
136+
.context(format!(
137+
"pip install failed (ran {:?} with {:?})",
138+
pip_cmd.get_program(),
139+
&pip_cmd.get_args().collect::<Vec<_>>(),
140+
))?;
141+
if !output.status.success() {
142+
bail!(
143+
"pip install in {} failed running {:?}: {}\n--- Stdout:\n{}\n--- Stderr:\n{}\n---\n",
144+
venv_dir.display(),
145+
&pip_cmd.get_args().collect::<Vec<_>>(),
146+
output.status,
147+
String::from_utf8_lossy(&output.stdout).trim(),
148+
String::from_utf8_lossy(&output.stderr).trim(),
149+
);
150+
}
151+
if !output.stderr.is_empty() {
152+
eprintln!(
153+
"⚠️ Warning: pip raised a warning running {:?}:\n{}",
154+
&pip_cmd.get_args().collect::<Vec<_>>(),
155+
String::from_utf8_lossy(&output.stderr).trim(),
156+
);
157+
}
158+
fix_direct_url(build_context, python, pip_path)?;
159+
Ok(())
160+
}
161+
162+
/// Each editable-installed python package has a direct_url.json file that includes a file:// URL
163+
/// indicating the location of the source code of that project. The maturin import hook uses this
164+
/// URL to locate and rebuild editable-installed projects.
165+
///
166+
/// When a maturin package is installed using `pip install -e`, pip takes care of writing the
167+
/// correct URL, however when a maturin package is installed with `maturin develop`, the URL is
168+
/// set to the path to the temporary wheel file created during installation.
169+
fn fix_direct_url(
170+
build_context: &BuildContext,
171+
python: &Path,
172+
pip_path: Option<&Path>,
173+
) -> Result<()> {
174+
println!("✏️ Setting installed package as editable");
175+
let mut pip_cmd = make_pip_command(python, pip_path);
176+
let output = pip_cmd
177+
.args(["show", "--files"])
178+
.arg(&build_context.metadata21.name)
179+
.output()
180+
.context(format!(
181+
"pip show failed (ran {:?} with {:?})",
182+
pip_cmd.get_program(),
183+
&pip_cmd.get_args().collect::<Vec<_>>(),
184+
))?;
185+
if let Some(direct_url_path) = parse_direct_url_path(&String::from_utf8_lossy(&output.stdout))?
186+
{
187+
let project_dir = build_context
188+
.pyproject_toml_path
189+
.parent()
190+
.ok_or_else(|| anyhow!("failed to get project directory"))?;
191+
let uri = Url::from_file_path(project_dir)
192+
.map_err(|_| anyhow!("failed to convert project directory to file URL"))?;
193+
let content = format!("{{\"dir_info\": {{\"editable\": true}}, \"url\": \"{uri}\"}}");
194+
fs::write(direct_url_path, content)?;
195+
}
196+
Ok(())
197+
}
198+
199+
fn parse_direct_url_path(pip_show_output: &str) -> Result<Option<PathBuf>> {
200+
if let Some(Some(location)) = Regex::new(r"Location: ([^\r\n]*)")?
201+
.captures(pip_show_output)
202+
.map(|c| c.get(1))
203+
{
204+
if let Some(Some(direct_url_path)) = Regex::new(r" (.*direct_url.json)")?
205+
.captures(pip_show_output)
206+
.map(|c| c.get(1))
207+
{
208+
return Ok(Some(
209+
PathBuf::from(location.as_str()).join(direct_url_path.as_str()),
210+
));
211+
}
212+
}
213+
Ok(None)
214+
}
215+
75216
/// Installs a crate by compiling it and copying the shared library to site-packages.
76217
/// Also adds the dist-info directory to make sure pip and other tools detect the library
77218
///
@@ -137,74 +278,18 @@ pub fn develop(develop_options: DevelopOptions, venv_dir: &Path) -> Result<()> {
137278
|| anyhow!("Expected `python` to be a python interpreter inside a virtualenv ಠ_ಠ"),
138279
)?;
139280

140-
// Install dependencies
141-
if !build_context.metadata21.requires_dist.is_empty() {
142-
let mut args = vec!["install".to_string()];
143-
args.extend(build_context.metadata21.requires_dist.iter().map(|x| {
144-
let mut pkg = x.clone();
145-
// Remove extra marker to make it installable with pip
146-
// Keep in sync with `Metadata21::merge_pyproject_toml()`!
147-
for extra in &extras {
148-
pkg.marker = pkg.marker.and_then(|marker| -> Option<MarkerTree> {
149-
match marker.clone() {
150-
MarkerTree::Expression(MarkerExpression {
151-
l_value: MarkerValue::Extra,
152-
operator: MarkerOperator::Equal,
153-
r_value: MarkerValue::QuotedString(extra_value),
154-
}) if &extra_value == extra => None,
155-
MarkerTree::And(and) => match &*and {
156-
[existing, MarkerTree::Expression(MarkerExpression {
157-
l_value: MarkerValue::Extra,
158-
operator: MarkerOperator::Equal,
159-
r_value: MarkerValue::QuotedString(extra_value),
160-
})] if extra_value == extra => Some(existing.clone()),
161-
_ => Some(marker),
162-
},
163-
_ => Some(marker),
164-
}
165-
});
166-
}
167-
pkg.to_string()
168-
}));
169-
let status = make_pip_command(&interpreter.executable, pip_path.as_deref())
170-
.args(&args)
171-
.status()
172-
.context("Failed to run pip install")?;
173-
if !status.success() {
174-
bail!(r#"pip install finished with "{}""#, status)
175-
}
176-
}
281+
install_dependencies(&build_context, &extras, &interpreter, pip_path.as_deref())?;
177282

178283
let wheels = build_context.build_wheels()?;
179284
if !skip_install {
180285
for (filename, _supported_version) in wheels.iter() {
181-
let mut pip_cmd = make_pip_command(&python, pip_path.as_deref());
182-
let output = pip_cmd
183-
.args(["install", "--no-deps", "--force-reinstall"])
184-
.arg(dunce::simplified(filename))
185-
.output()
186-
.context(format!(
187-
"pip install failed (ran {:?} with {:?})",
188-
pip_cmd.get_program(),
189-
&pip_cmd.get_args().collect::<Vec<_>>(),
190-
))?;
191-
if !output.status.success() {
192-
bail!(
193-
"pip install in {} failed running {:?}: {}\n--- Stdout:\n{}\n--- Stderr:\n{}\n---\n",
194-
venv_dir.display(),
195-
&pip_cmd.get_args().collect::<Vec<_>>(),
196-
output.status,
197-
String::from_utf8_lossy(&output.stdout).trim(),
198-
String::from_utf8_lossy(&output.stderr).trim(),
199-
);
200-
}
201-
if !output.stderr.is_empty() {
202-
eprintln!(
203-
"⚠️ Warning: pip raised a warning running {:?}:\n{}",
204-
&pip_cmd.get_args().collect::<Vec<_>>(),
205-
String::from_utf8_lossy(&output.stderr).trim(),
206-
);
207-
}
286+
pip_install_wheel(
287+
&build_context,
288+
&python,
289+
venv_dir,
290+
pip_path.as_deref(),
291+
filename,
292+
)?;
208293
eprintln!(
209294
"🛠 Installed {}-{}",
210295
build_context.metadata21.name, build_context.metadata21.version
@@ -214,3 +299,79 @@ pub fn develop(develop_options: DevelopOptions, venv_dir: &Path) -> Result<()> {
214299

215300
Ok(())
216301
}
302+
303+
#[cfg(test)]
304+
mod test {
305+
use std::path::PathBuf;
306+
307+
use super::parse_direct_url_path;
308+
309+
#[test]
310+
#[cfg(not(target_os = "windows"))]
311+
fn test_parse_direct_url() {
312+
let example_with_direct_url = "\
313+
Name: my-project
314+
Version: 0.1.0
315+
Location: /foo bar/venv/lib/pythonABC/site-packages
316+
Editable project location: /tmp/temporary.whl
317+
Files:
318+
my_project-0.1.0+abc123de.dist-info/INSTALLER
319+
my_project-0.1.0+abc123de.dist-info/METADATA
320+
my_project-0.1.0+abc123de.dist-info/RECORD
321+
my_project-0.1.0+abc123de.dist-info/REQUESTED
322+
my_project-0.1.0+abc123de.dist-info/WHEEL
323+
my_project-0.1.0+abc123de.dist-info/direct_url.json
324+
my_project-0.1.0+abc123de.dist-info/entry_points.txt
325+
my_project.pth
326+
";
327+
let expected_path = PathBuf::from("/foo bar/venv/lib/pythonABC/site-packages/my_project-0.1.0+abc123de.dist-info/direct_url.json");
328+
assert_eq!(
329+
parse_direct_url_path(example_with_direct_url).unwrap(),
330+
Some(expected_path)
331+
);
332+
333+
let example_without_direct_url = "\
334+
Name: my-project
335+
Version: 0.1.0
336+
Location: /foo bar/venv/lib/pythonABC/site-packages
337+
Files:
338+
my_project-0.1.0+abc123de.dist-info/INSTALLER
339+
my_project-0.1.0+abc123de.dist-info/METADATA
340+
my_project-0.1.0+abc123de.dist-info/RECORD
341+
my_project-0.1.0+abc123de.dist-info/REQUESTED
342+
my_project-0.1.0+abc123de.dist-info/WHEEL
343+
my_project-0.1.0+abc123de.dist-info/entry_points.txt
344+
my_project.pth
345+
";
346+
347+
assert_eq!(
348+
parse_direct_url_path(example_without_direct_url).unwrap(),
349+
None
350+
);
351+
}
352+
353+
#[test]
354+
#[cfg(target_os = "windows")]
355+
fn test_parse_direct_url_windows() {
356+
let example_with_direct_url_windows = "\
357+
Name: my-project\r
358+
Version: 0.1.0\r
359+
Location: C:\\foo bar\\venv\\Lib\\site-packages\r
360+
Files:\r
361+
my_project-0.1.0+abc123de.dist-info\\INSTALLER\r
362+
my_project-0.1.0+abc123de.dist-info\\METADATA\r
363+
my_project-0.1.0+abc123de.dist-info\\RECORD\r
364+
my_project-0.1.0+abc123de.dist-info\\REQUESTED\r
365+
my_project-0.1.0+abc123de.dist-info\\WHEEL\r
366+
my_project-0.1.0+abc123de.dist-info\\direct_url.json\r
367+
my_project-0.1.0+abc123de.dist-info\\entry_points.txt\r
368+
my_project.pth\r
369+
";
370+
371+
let expected_path = PathBuf::from("C:\\foo bar\\venv\\Lib\\site-packages\\my_project-0.1.0+abc123de.dist-info\\direct_url.json");
372+
assert_eq!(
373+
parse_direct_url_path(example_with_direct_url_windows).unwrap(),
374+
Some(expected_path)
375+
);
376+
}
377+
}

0 commit comments

Comments
 (0)