diff --git a/Changelog.md b/Changelog.md index 83eb97e61..41f0f5827 100644 --- a/Changelog.md +++ b/Changelog.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * auditwheel: add `libz.so.1` to whitelisted libraries in [#625](https://github.com/PyO3/maturin/pull/625) * auditwheel: detect musl libc in [#629](https://github.com/PyO3/maturin/pull/629) * Fixed Python 3.10 and later versions detection on Windows in [#630](https://github.com/PyO3/maturin/pull/630) +* Install entrypoint scripts in `maturin develop` command on Unix in [#633](https://github.com/PyO3/maturin/pull/633) ## [0.11.3] - 2021-08-25 diff --git a/src/build_options.rs b/src/build_options.rs index ca2bf973c..75dc8b477 100644 --- a/src/build_options.rs +++ b/src/build_options.rs @@ -523,8 +523,7 @@ pub fn find_interpreter( env::set_var("PYTHON_SYS_EXECUTABLE", &host_python.executable); let sysconfig_path = find_sysconfigdata(cross_lib_dir.as_ref(), target)?; - let sysconfig_data = - parse_sysconfigdata(&host_python.executable, sysconfig_path)?; + let sysconfig_data = parse_sysconfigdata(host_python, sysconfig_path)?; let major = sysconfig_data .get("version_major") .context("version_major is not defined")? diff --git a/src/cross_compile.rs b/src/cross_compile.rs index 135888912..2f992d178 100644 --- a/src/cross_compile.rs +++ b/src/cross_compile.rs @@ -1,12 +1,10 @@ use crate::target::get_host_target; -use crate::Target; +use crate::{PythonInterpreter, Target}; use anyhow::{bail, Result}; use fs_err::{self as fs, DirEntry}; use std::collections::HashMap; use std::env; -use std::io; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; pub fn is_cross_compiling(target: &Target) -> Result { let target_triple = target.target_triple(); @@ -45,7 +43,7 @@ pub fn is_cross_compiling(target: &Target) -> Result { /// python executable and library. Here it is read and added to a script to extract only what is /// necessary. This necessitates a python interpreter for the host machine to work. pub fn parse_sysconfigdata( - interpreter: &Path, + interpreter: &PythonInterpreter, config_path: impl AsRef, ) -> Result> { let mut script = fs::read_to_string(config_path)?; @@ -60,7 +58,7 @@ KEYS = [ for key in KEYS: print(key, build_time_vars.get(key, "")) "#; - let output = run_python_script(interpreter, &script)?; + let output = interpreter.run_script(&script)?; Ok(parse_script_output(&output)) } @@ -75,45 +73,6 @@ fn parse_script_output(output: &str) -> HashMap { .collect() } -/// Run a python script using the specified interpreter binary. -fn run_python_script(interpreter: &Path, script: &str) -> Result { - let out = Command::new(interpreter) - .env("PYTHONIOENCODING", "utf-8") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn() - .and_then(|mut child| { - use std::io::Write; - child - .stdin - .as_mut() - .expect("piped stdin") - .write_all(script.as_bytes())?; - child.wait_with_output() - }); - - match out { - Err(err) => { - if err.kind() == io::ErrorKind::NotFound { - bail!( - "Could not find any interpreter at {}, \ - are you sure you have Python installed on your PATH?", - interpreter.display() - ); - } else { - bail!( - "Failed to run the Python interpreter at {}: {}", - interpreter.display(), - err - ); - } - } - Ok(ok) if !ok.status.success() => bail!("Python script failed"), - Ok(ok) => Ok(String::from_utf8(ok.stdout)?), - } -} - fn starts_with(entry: &DirEntry, pat: &str) -> bool { let name = entry.file_name(); name.to_string_lossy().starts_with(pat) diff --git a/src/develop.rs b/src/develop.rs index 0e74bed9e..6b54d5b95 100644 --- a/src/develop.rs +++ b/src/develop.rs @@ -3,10 +3,15 @@ use crate::compile::compile; use crate::module_writer::{write_bindings_module, write_cffi_module, PathWriter}; use crate::PythonInterpreter; use crate::Target; -use crate::{write_dist_info, BuildOptions}; +use crate::{write_dist_info, BuildOptions, Metadata21}; use crate::{ModuleWriter, PlatformTag}; use anyhow::{anyhow, bail, format_err, Context, Result}; use fs_err as fs; +#[cfg(not(target_os = "windows"))] +use std::fs::OpenOptions; +use std::io::Write; +#[cfg(not(target_os = "windows"))] +use std::os::unix::fs::OpenOptionsExt; use std::path::Path; use std::process::Command; @@ -191,5 +196,109 @@ pub fn develop( writer.write_record(&build_context.metadata21)?; + write_entry_points(&interpreter, &build_context.metadata21)?; + + Ok(()) +} + +/// https://packaging.python.org/specifications/entry-points/ +/// +/// entry points examples: +/// 1. `foomod:main` +/// 2. `foomod:main_bar [bar,baz]` where `bar` and `baz` are extra requires +fn parse_entry_point(entry: &str) -> Option<(&str, &str)> { + // remove extras since we don't care about them + let entry = entry + .split_once(' ') + .map(|(first, _)| first) + .unwrap_or(entry); + entry.split_once(':') +} + +/// Build a shebang line. In the simple case (on Windows, or a shebang line +/// which is not too long or contains spaces) use a simple formulation for +/// the shebang. Otherwise, use /bin/sh as the executable, with a contrived +/// shebang which allows the script to run either under Python or sh, using +/// suitable quoting. Thanks to Harald Nordgren for his input. +/// See also: http://www.in-ulm.de/~mascheck/various/shebang/#length +/// https://hg.mozilla.org/mozilla-central/file/tip/mach +fn get_shebang(executable: &Path) -> String { + let executable = executable.display().to_string(); + if cfg!(unix) { + let max_length = if cfg!(target_os = "macos") { 512 } else { 127 }; + // Add 3 for '#!' prefix and newline suffix. + let shebang_length = executable.len() + 3; + if !executable.contains(' ') && shebang_length <= max_length { + return format!("#!{}\n", executable); + } + let mut shebang = "#!/bin/sh\n".to_string(); + shebang.push_str(&format!("'''exec' {} \"$0\" \"$@\"\n' '''", executable)); + shebang + } else { + format!("#!{}\n", executable) + } +} + +fn write_entry_points(interpreter: &PythonInterpreter, metadata21: &Metadata21) -> Result<()> { + if cfg!(target_os = "windows") { + // FIXME: add Windows support + return Ok(()); + } + let code = "import sysconfig; print(sysconfig.get_path('scripts'))"; + let script_dir = interpreter.run_script(code)?; + let script_dir = Path::new(script_dir.trim()); + // FIXME: On Windows shebang has to be used with Python launcher + let shebang = get_shebang(&interpreter.executable); + for (name, entry) in metadata21 + .scripts + .iter() + .chain(metadata21.gui_scripts.iter()) + { + let (module, func) = + parse_entry_point(entry).context("Invalid entry point specification")?; + let import_name = func.split_once('.').map(|(first, _)| first).unwrap_or(func); + let script = format!( + r#"# -*- coding: utf-8 -*- +import re +import sys +from {module} import {import_name} +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit({func}()) +"#, + module = module, + import_name = import_name, + func = func, + ); + let script = shebang.clone() + &script; + // FIXME: on Windows scripts needs to have .exe extension + let script_path = script_dir.join(name); + // We only need to set the executable bit on unix + let mut file = { + #[cfg(not(target_os = "windows"))] + { + OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o755) + .open(&script_path) + } + #[cfg(target_os = "windows")] + { + fs::File::create(&script_path) + } + } + .context(format!( + "Failed to create a file at {}", + script_path.display() + ))?; + + file.write_all(script.as_bytes()).context(format!( + "Failed to write to file at {}", + script_path.display() + ))?; + } + Ok(()) } diff --git a/src/python_interpreter.rs b/src/python_interpreter.rs index c9212d639..403c397f4 100644 --- a/src/python_interpreter.rs +++ b/src/python_interpreter.rs @@ -7,7 +7,7 @@ use std::collections::HashSet; use std::fmt; use std::io; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use std::str; /// This snippets will give us information about the python interpreter's @@ -546,6 +546,45 @@ impl PythonInterpreter { Ok(available_versions) } + + /// Run a python script using this Python interpreter. + pub fn run_script(&self, script: &str) -> Result { + let out = Command::new(&self.executable) + .env("PYTHONIOENCODING", "utf-8") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + child + .stdin + .as_mut() + .expect("piped stdin") + .write_all(script.as_bytes())?; + child.wait_with_output() + }); + + match out { + Err(err) => { + if err.kind() == io::ErrorKind::NotFound { + bail!( + "Could not find any interpreter at {}, \ + are you sure you have Python installed on your PATH?", + self.executable.display() + ); + } else { + bail!( + "Failed to run the Python interpreter at {}: {}", + self.executable.display(), + err + ); + } + } + Ok(ok) if !ok.status.success() => bail!("Python script failed"), + Ok(ok) => Ok(String::from_utf8(ok.stdout)?), + } + } } impl fmt::Display for PythonInterpreter { diff --git a/test-crates/pyo3-pure/check_installed/check_installed.py b/test-crates/pyo3-pure/check_installed/check_installed.py index 86fe46115..8063fd401 100755 --- a/test-crates/pyo3-pure/check_installed/check_installed.py +++ b/test-crates/pyo3-pure/check_installed/check_installed.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import os +import subprocess import pyo3_pure @@ -10,4 +11,8 @@ assert os.path.exists(os.path.join(install_path, "__init__.pyi")) assert os.path.exists(os.path.join(install_path, "py.typed")) +# Check entrypoints (Unix only for now) +if os.name != "nt": + assert subprocess.run(["get_42"]).returncode == 42 + print("SUCCESS")