use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use proc_macro2::TokenStream;
use python_ast::{
parse_enhanced, python_annotation_to_rust_type, safe_ident, CodeGen, CodeGenContext,
PythonOptions, StatementType, SymbolTableScopes,
};
use quote::quote;
use rust_format::{Formatter, RustFmt};
use crate::package::{PyModule, PyPackage};
const GENERATED_LINTS: &str = "unused_imports, unused_variables, unused_mut, unused_assignments, dead_code, unreachable_code, non_snake_case, non_upper_case_globals, deprecated, noop_method_call";
fn generated_lint_attrs(mode: WarningMode) -> String {
match mode {
WarningMode::Warn => String::new(),
WarningMode::Deny => format!("#![deny({})]\n", GENERATED_LINTS),
WarningMode::Allow => format!("#![allow({})]\n", GENERATED_LINTS),
}
}
fn no_std_attr(opts: &ConvertOptions) -> &'static str {
if opts.no_std { "#![no_std]\n" } else { "" }
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum WarningMode {
#[default]
Warn,
Deny,
Allow,
}
#[derive(Debug, Clone, Default)]
pub struct ConvertOptions {
pub pyo3: bool,
pub stdpython_path: Option<PathBuf>,
pub warnings: WarningMode,
pub no_std: bool,
}
#[derive(Debug)]
pub struct ConvertedCrate {
pub root: PathBuf,
pub name: String,
pub has_binary: bool,
pub warnings: Vec<String>,
}
pub fn convert(package: &PyPackage, out_dir: &Path, opts: &ConvertOptions) -> Result<ConvertedCrate> {
if opts.no_std && opts.pyo3 {
bail!("PyO3 bindings require std (pyo3 links the Python runtime); drop one of --pyo3 / --no-std");
}
let src_dir = out_dir.join("src");
fs::create_dir_all(&src_dir)
.with_context(|| format!("creating {}", src_dir.display()))?;
let entry_file = package.entry_module().map(|m| m.file.clone());
let mut transpiled: Vec<(&PyModule, String)> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
for module in &package.modules {
let code = transpile(module, &mut warnings, opts)?;
transpiled.push((module, code));
}
let bindings_text = if opts.pyo3 {
Some(generate_bindings(package, &transpiled, &mut warnings)?)
} else {
None
};
match opts.warnings {
WarningMode::Deny if !warnings.is_empty() => bail!(
"lossy conversion (warnings denied):\n {}",
warnings.join("\n ")
),
WarningMode::Allow => warnings.clear(),
_ => {}
}
let mut children: BTreeMap<Vec<String>, Vec<String>> = BTreeMap::new();
for (module, _) in &transpiled {
if module.path.is_empty() || is_dunder_main(module) {
continue;
}
let (parent, name) = module.path.split_at(module.path.len() - 1);
children
.entry(parent.to_vec())
.or_default()
.push(name[0].clone());
for depth in 1..parent.len() + 1 {
let (ancestor, child) = parent.split_at(depth - 1);
let list = children.entry(ancestor.to_vec()).or_default();
if !list.contains(&child[0].to_string()) {
list.push(child[0].clone());
}
}
}
for (module, code) in &transpiled {
if is_dunder_main(module) {
continue; }
let is_root = module.path.is_empty();
let decls = mod_decls(&children, &module.path, module.is_init || is_root);
let allows = if is_root {
format!("{}{}", no_std_attr(opts), generated_lint_attrs(opts.warnings))
} else {
String::new()
};
let contents = format!("{}{}\n{}", allows, code, decls);
let file = module_file_path(&src_dir, module);
if let Some(parent) = file.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&file, format_rust(&contents))
.with_context(|| format!("writing {}", file.display()))?;
}
let lib_rs = src_dir.join("lib.rs");
if !lib_rs.exists() {
let decls = mod_decls(&children, &[], true);
fs::write(
&lib_rs,
format_rust(&format!(
"{}{}{}",
no_std_attr(opts),
generated_lint_attrs(opts.warnings),
decls
)),
)?;
}
if let Some(bindings) = &bindings_text {
fs::write(src_dir.join("python_api.rs"), format_rust(bindings))?;
let mut lib = fs::read_to_string(&lib_rs)?;
lib.push_str("\n#[cfg(feature = \"python\")]\nmod python_api;\n");
fs::write(&lib_rs, format_rust(&lib))?;
}
let mut has_binary = false;
if let Some(entry_file) = &entry_file {
let (entry, code) = transpiled
.iter()
.find(|(m, _)| &m.file == entry_file)
.expect("entry module was transpiled");
let decls = if !is_dunder_main(entry) && entry.path.len() == 1 {
let mut decls = String::new();
if let Some(kids) = children.get(&Vec::new()) {
for kid in kids {
if Some(kid) != entry.path.first() {
decls.push_str(&format!("mod {};\n", kid));
}
}
}
decls
} else {
mod_decls(&children, &[], true).replace("pub mod", "mod")
};
let main_contents = format!(
"{}{}\n{}",
generated_lint_attrs(opts.warnings),
code,
decls
);
fs::write(src_dir.join("main.rs"), format_rust(&main_contents))?;
has_binary = true;
}
write_cargo_toml(package, out_dir, opts, has_binary)?;
Ok(ConvertedCrate {
root: out_dir.to_path_buf(),
name: package.name.clone(),
has_binary,
warnings,
})
}
fn is_dunder_main(module: &PyModule) -> bool {
module.path.last().map(String::as_str) == Some("__main__")
}
fn parse_filename(module: &PyModule) -> String {
if module.is_init {
if module.path.is_empty() {
"__init__.py".to_string()
} else {
format!("{}/__init__.py", module.path.join("/"))
}
} else {
format!("{}.py", module.path.join("/"))
}
}
fn transpile(
module: &PyModule,
warnings: &mut Vec<String>,
opts: &ConvertOptions,
) -> Result<String> {
let mode = opts.warnings;
let ast = parse_enhanced(&module.source, parse_filename(module))
.map_err(|e| anyhow::anyhow!("{} ({})", e, module.file.display()))?;
for stmt in &ast.raw.body {
if let StatementType::FunctionDef(func) = &stmt.statement {
for note in func.lossy_conversion_notes() {
warnings.push(format!(
"{}: function `{}`: {}",
parse_filename(module),
func.name,
note.trim_start_matches("rython: "),
));
}
}
}
let symbols = ast.clone().find_symbols(SymbolTableScopes::new());
let module_name = module
.path
.last()
.cloned()
.unwrap_or_else(|| "lib".to_string());
let options = PythonOptions {
lossy_warnings: mode != WarningMode::Allow,
no_std: opts.no_std,
..Default::default()
};
let tokens = ast
.to_rust(
CodeGenContext::Module(module_name),
options,
symbols,
)
.map_err(|e| {
anyhow::anyhow!(
"compiling {}: {}",
module.file.display(),
python_ast::format_error_chain(e.as_ref())
)
})?;
Ok(tokens.to_string())
}
fn mod_decls(
children: &BTreeMap<Vec<String>, Vec<String>>,
at: &[String],
_is_container: bool,
) -> String {
let mut out = String::new();
if let Some(kids) = children.get(at) {
let mut sorted = kids.clone();
sorted.sort();
sorted.dedup();
for kid in sorted {
out.push_str(&format!("pub mod {};\n", kid));
}
}
out
}
fn module_file_path(src_dir: &Path, module: &PyModule) -> PathBuf {
if module.path.is_empty() {
return src_dir.join("lib.rs");
}
if module.is_init {
let mut dir = src_dir.to_path_buf();
for part in &module.path {
dir = dir.join(part);
}
return dir.join("mod.rs");
}
let (dirs, name) = module.path.split_at(module.path.len() - 1);
let mut dir = src_dir.to_path_buf();
for part in dirs {
dir = dir.join(part);
}
dir.join(format!("{}.rs", name[0]))
}
fn generate_bindings(
package: &PyPackage,
transpiled: &[(&PyModule, String)],
warnings: &mut Vec<String>,
) -> Result<String> {
type Signature = (Vec<TokenStream>, Vec<TokenStream>, Option<TokenStream>);
let mut candidates: Vec<(&PyModule, python_ast::FunctionDef, Signature)> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for (module, _) in transpiled {
if is_dunder_main(module) {
continue;
}
let ast = parse_enhanced(&module.source, parse_filename(module))
.map_err(|e| anyhow::anyhow!("{} ({})", e, module.file.display()))?;
for stmt in &ast.raw.body {
let StatementType::FunctionDef(func) = &stmt.statement else {
continue;
};
if func.name.starts_with('_') {
continue;
}
match bindable_signature(func) {
Some(sig) => candidates.push((module, func.clone(), sig)),
None => skipped.push(format!("{}.{}", module.path.join("."), func.name)),
}
}
}
let mut name_counts: BTreeMap<&str, usize> = BTreeMap::new();
for (_, func, _) in &candidates {
*name_counts.entry(func.name.as_str()).or_default() += 1;
}
let mut wrappers: Vec<TokenStream> = Vec::new();
let mut registrations: Vec<TokenStream> = Vec::new();
let mut collisions: BTreeMap<&str, Vec<String>> = BTreeMap::new();
for (module, func, (params, arg_names, ret)) in &candidates {
let bare = func.name.as_str();
let qualified = if module.path.is_empty() {
bare.to_string()
} else {
format!("{}_{}", module.path.join("_"), bare)
};
let wrapper_name = safe_ident(&qualified);
let target = safe_ident(bare);
let path: Vec<_> = module.path.iter().map(|p| safe_ident(p)).collect();
let call = quote!(crate::#(#path::)*#target(#(#arg_names),*));
let ret_tokens = match ret {
Some(ty) => quote!(-> pyo3::PyResult<#ty>),
None => quote!(-> pyo3::PyResult<()>),
};
let body = quote!(#call.map_err(pyo3::PyErr::from));
let py_name = if name_counts[bare] == 1 {
quote!(#[pyo3(name = #bare)])
} else {
collisions.entry(bare).or_default().push(qualified.clone());
quote!()
};
wrappers.push(quote! {
#[pyfunction]
#py_name
fn #wrapper_name(#(#params),*) #ret_tokens {
#body
}
});
registrations.push(quote! {
m.add_function(wrap_pyfunction!(#wrapper_name, m)?)?;
});
}
for (bare, qualified) in &collisions {
warnings.push(format!(
"python bindings: {} modules define a function named `{}`; they are \
exposed to Python under module-qualified names (`{}`) because a \
module cannot hold two same-named functions",
qualified.len(),
bare,
qualified.join("`, `"),
));
}
if wrappers.is_empty() {
bail!(
"no functions with bindable signatures found; annotate parameters \
with int/float/str/bool types to expose them to Python{}",
if skipped.is_empty() {
String::new()
} else {
format!(" (skipped: {})", skipped.join(", "))
}
);
}
let module_name = safe_ident(&package.name);
let skipped_note = if skipped.is_empty() {
String::new()
} else {
format!(
"//! Skipped (signature not expressible in concrete Rust types yet): {}\n",
skipped.join(", ")
)
};
let bindings = quote! {
use pyo3::prelude::*;
#(#wrappers)*
#[pymodule]
fn #module_name(m: &Bound<'_, PyModule>) -> PyResult<()> {
#(#registrations)*
Ok(())
}
};
Ok(format!(
"//! PyO3 bindings generated by rypip.\n{}{}",
skipped_note, bindings
))
}
#[allow(clippy::type_complexity)]
fn bindable_signature(
func: &python_ast::FunctionDef,
) -> Option<(Vec<TokenStream>, Vec<TokenStream>, Option<TokenStream>)> {
if func.args.vararg.is_some()
|| func.args.kwarg.is_some()
|| !func.args.kwonlyargs.is_empty()
|| !func.args.posonlyargs.is_empty()
|| !func.args.defaults.is_empty()
{
return None;
}
let mut params = Vec::new();
let mut names = Vec::new();
for param in &func.args.args {
let annotation = param.annotation.as_deref()?;
let ty = python_annotation_to_rust_type(annotation)?;
let name = safe_ident(¶m.arg);
params.push(quote!(#name: #ty));
names.push(quote!(#name));
}
let ret = func.resolved_return_type();
Some((params, names, ret))
}
fn write_cargo_toml(
package: &PyPackage,
out_dir: &Path,
opts: &ConvertOptions,
_has_binary: bool,
) -> Result<()> {
let stdpython_source = resolve_stdpython_source(opts)?;
let stdpython_dep = match (&stdpython_source, opts.no_std) {
(StdpythonSource::Path(path), true) => format!(
"stdpython = {{ path = \"{}\", default-features = false, features = [\"alloc\"] }}",
path.display().to_string().replace('\\', "/"),
),
(StdpythonSource::Path(path), false) => format!(
"stdpython = {{ path = \"{}\" }}",
path.display().to_string().replace('\\', "/"),
),
(StdpythonSource::Registry(version), true) => format!(
"stdpython = {{ version = \"{}\", default-features = false, features = [\"alloc\"] }}",
version,
),
(StdpythonSource::Registry(version), false) => {
format!("stdpython = \"{}\"", version)
}
};
let mut toml = format!(
"# Generated by rypip from a Python package. Edit freely.\n\
[package]\n\
name = \"{name}\"\n\
version = \"{version}\"\n\
edition = \"2021\"\n\n\
[dependencies]\n\
{stdpython_dep}\n",
name = package.name,
version = package.version,
);
if opts.pyo3 {
toml.push_str(
"pyo3 = { version = \"0.29\", features = [\"extension-module\"], optional = true }\n\n\
[features]\n\
python = [\"dep:pyo3\"]\n\n\
[lib]\n\
crate-type = [\"lib\", \"cdylib\"]\n",
);
}
fs::write(out_dir.join("Cargo.toml"), toml)?;
let manifest = out_dir.join("Cargo.toml");
let mut text = fs::read_to_string(&manifest)?;
text.push_str("\n[workspace]\n");
fs::write(manifest, text)?;
Ok(())
}
enum StdpythonSource {
Path(PathBuf),
Registry(String),
}
fn resolve_stdpython_source(opts: &ConvertOptions) -> Result<StdpythonSource> {
if let Some(path) = &opts.stdpython_path {
return path
.canonicalize()
.map(StdpythonSource::Path)
.with_context(|| format!("stdpython path {} not found", path.display()));
}
if let Ok(env_path) = std::env::var("RYPIP_STDPYTHON_PATH") {
return PathBuf::from(&env_path)
.canonicalize()
.map(StdpythonSource::Path)
.with_context(|| format!("RYPIP_STDPYTHON_PATH {} not found", env_path));
}
let built_in = Path::new(env!("CARGO_MANIFEST_DIR")).join("../stdpython");
if let Ok(path) = built_in.canonicalize() {
return Ok(StdpythonSource::Path(path));
}
Ok(StdpythonSource::Registry(
env!("CARGO_PKG_VERSION").to_string(),
))
}
fn format_rust(source: &str) -> String {
RustFmt::default()
.format_str(source)
.unwrap_or_else(|_| source.to_string())
}