blob: 5eda8b581f5a3537ab2dd2697192e4cf12cf4b74 [file] [log] [blame]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use crate::generate::parser::{ConfigType, Services, sorted_services};
use anyhow::Result;
use minijinja::value::ViaDeserialize;
use minijinja::{Environment, context};
use std::fs;
use std::path::PathBuf;
fn enabled_service(srv: &str) -> bool {
match srv {
// not enabled in bindings/python/Cargo.toml
"etcd" | "foundationdb" | "hdfs" | "rocksdb" | "tikv" | "github"
| "cloudflare_kv" | "monoiofs" | "dbfs" | "surrealdb" | "d1" | "opfs" | "compfs"
| "lakefs" | "pcloud" | "vercel_blob" => false,
_ => true,
}
}
pub fn generate(workspace_dir: PathBuf, services: Services) -> Result<()> {
let srvs = sorted_services(services, enabled_service);
let mut env = Environment::new();
env.add_template("python", include_str!("python.j2"))?;
env.add_function("snake_to_kebab_case", snake_to_kebab_case);
env.add_function("service_to_feature", service_to_feature);
env.add_function("service_to_pascal", service_to_pascal);
env.add_function("make_python_type", make_python_type);
env.add_function("make_pydoc_param_header", make_pydoc_param_header);
env.add_function("make_pydoc_param", make_pydoc_param);
let tmpl = env.get_template("python")?;
let output = workspace_dir.join("bindings/python/src/services.rs");
fs::write(output, tmpl.render(context! { srvs => srvs })?)?;
Ok(())
}
fn snake_to_kebab_case(str: &str) -> String {
str.replace('_', "-")
}
fn service_to_feature(service: &str) -> String {
format!("services-{}", snake_to_kebab_case(service))
}
fn service_to_pascal(service: &str) -> String {
let mut result = String::with_capacity(service.len());
let mut capitalize = true;
for &b in service.as_bytes() {
if b == b'_' || b == b'-' {
capitalize = true;
} else if capitalize {
result.push((b as char).to_ascii_uppercase());
capitalize = false;
} else {
result.push(b as char);
}
}
result
}
/// Format a block of text with trimming, wrapping, and indentation.
///
/// # Arguments
/// * `text` - The input text to format.
/// * `indent` - Number of spaces to prepend to each line.
/// * `max_line_length` - Maximum total line width including indentation.
///
/// # Returns
/// A formatted string where:
/// - Paragraphs and sentences are split properly.
/// - Each line is trimmed and wrapped within `max_line_length`.
/// - All lines end with a single `\n` (except the very last one).
/// - Whitespace is normalized and excess whitespace is removed.
pub fn format_text(text: &str, indent: usize, max_line_length: usize) -> String {
// Precompute indentation string
let indent_str = " ".repeat(indent);
let effective_width = max_line_length.saturating_sub(indent);
// Preallocate output buffer with a rough estimate (input len + ~20%)
let mut output = String::with_capacity(text.len() + text.len() / 5);
// Small closure to normalize whitespace (single space, trim edges)
let normalize = |s: &str| {
let mut result = String::with_capacity(s.len());
let mut prev_space = true;
for ch in s.chars() {
if ch.is_whitespace() {
if !prev_space {
result.push(' ');
prev_space = true;
}
} else {
result.push(ch);
prev_space = false;
}
}
result.trim().to_owned()
};
// Split input into "segments" (roughly sentences or paragraphs)
let mut start = 0;
let chars: Vec<char> = text.chars().collect();
let mut segments = Vec::new();
for (i, &ch) in chars.iter().enumerate() {
match ch {
'.' | '!' | '?' => {
// Sentence end
if i + 1 < chars.len() && chars[i + 1].is_whitespace() {
segments.push(&text[start..=i]);
start = i + 1;
}
}
'\n' => {
if i > start {
segments.push(&text[start..i]);
}
start = i + 1;
}
_ => {}
}
}
if start < text.len() {
segments.push(&text[start..]);
}
// Process each normalized segment
for segment in segments {
let normalized = normalize(segment);
if normalized.is_empty() {
continue;
}
let mut line = String::with_capacity(effective_width);
for word in normalized.split_whitespace() {
if line.is_empty() {
line.push_str(word);
} else if line.len() + 1 + word.len() <= effective_width {
line.push(' ');
line.push_str(word);
} else {
// Emit current line
output.push_str(&indent_str);
output.push_str(line.trim_end());
output.push('\n');
// Start new line with this word
line.clear();
line.push_str(word);
}
}
// Emit any remaining words in this paragraph
if !line.is_empty() {
output.push_str(&indent_str);
output.push_str(line.trim_end());
output.push('\n');
}
}
// Remove any trailing newline(s)
while output.ends_with('\n') {
output.pop();
}
output
}
// Compose first line: param_name : type[, optional]
pub fn make_pydoc_param_header(name: &str, ty: ViaDeserialize<ConfigType>, optional: bool) -> Result<String, minijinja::Error> {
let py_type = make_python_type(ty)?;
let optional_str = if optional { ", optional" } else { "" };
let first_line = format!("{} : {}{}", name, py_type, optional_str);
Ok(first_line)
}
/// Generate a properly indented NumPy-style docstring line for a parameter
pub fn make_pydoc_param(comments: &str, ty: ViaDeserialize<ConfigType>) -> Result<String, minijinja::Error> {
// Collect description parts
let mut desc_parts = Vec::new();
let comments = comments.trim();
if !comments.is_empty() {
let trimmed = comments.replace('\n', " ");
if !trimmed.is_empty() {
desc_parts.push(trimmed);
}
}
match ty.0 {
ConfigType::Duration => desc_parts.push(
"a human readable duration string see https://docs.rs/humantime/latest/humantime/fn.parse_duration.html for more details".to_string()
),
ConfigType::Vec => desc_parts.push(
"A \",\" separated string, for example \"127.0.0.1:1,127.0.0.1:2\"".to_string()
),
_ => {}
}
Ok(format_text(&desc_parts.join(". "), 20, 72).trim_end().to_string())
}
fn make_python_type(ty: ViaDeserialize<ConfigType>) -> Result<String, minijinja::Error> {
Ok(match ty.0 {
ConfigType::Bool => "builtins.bool",
ConfigType::Duration => "typing.Any",
ConfigType::I64
| ConfigType::Usize
| ConfigType::U64
| ConfigType::U32
| ConfigType::U16 => "builtins.int",
ConfigType::Vec => "typing.Any",
ConfigType::String => "builtins.str",
}
.to_string())
}