PyO3 DSPy Type-Safe Signatures - Complete Reference
Comprehensive technical reference for building type-safe DSPy integrations in Rust, covering complete type mappings, Pydantic integration, custom converters, code generation, validation, and production patterns.
Table of Contents
- Type Mapping Reference
- Signature Extraction
- Pydantic Integration
- Custom Type Converters
- Generic Wrappers
- Error Handling
- Code Generation
- Testing Strategies
- Best Practices Summary
Type Mapping Reference
Primitive Types
Complete mapping table:
| Python Type | Rust Type | PyO3 Extract | Notes |
|---|---|---|---|
str |
String |
extract::<String>() |
Owned UTF-8 string |
int |
i64 |
extract::<i64>() |
64-bit signed |
int |
i32 |
extract::<i32>() |
32-bit signed |
int |
usize |
extract::<usize>() |
Platform-dependent |
float |
f64 |
extract::<f64>() |
64-bit float |
float |
f32 |
extract::<f32>() |
32-bit float |
bool |
bool |
extract::<bool>() |
Boolean |
None |
() |
extract::<()>() |
Unit type |
bytes |
Vec<u8> |
extract::<Vec<u8>>() |
Binary data |
Example: Primitive extraction:
use pyo3::prelude::*;
fn extract_primitives(pred: &PyAny) -> PyResult<()> {
// String
let text: String = pred.getattr("text")?.extract()?;
// Integer
let count: i64 = pred.getattr("count")?.extract()?;
// Float
let score: f64 = pred.getattr("score")?.extract()?;
// Boolean
let is_valid: bool = pred.getattr("is_valid")?.extract()?;
println!("text={}, count={}, score={}, valid={}",
text, count, score, is_valid);
Ok(())
}
Collection Types
List/Vec mapping:
| Python Type | Rust Type | Example |
|---|---|---|
List[str] |
Vec<String> |
extract::<Vec<String>>() |
List[int] |
Vec<i64> |
extract::<Vec<i64>>() |
List[float] |
Vec<f64> |
extract::<Vec<f64>>() |
List[bool] |
Vec<bool> |
extract::<Vec<bool>>() |
List[List[str]] |
Vec<Vec<String>> |
Nested vectors |
Example: List extraction:
use pyo3::prelude::*;
use pyo3::types::PyList;
fn extract_list_safe(pred: &PyAny) -> PyResult<Vec<String>> {
let sources = pred.getattr("sources")?;
// Method 1: Direct extraction (simplest)
let items: Vec<String> = sources.extract()?;
// Method 2: Manual iteration (more control)
let py_list = sources.downcast::<PyList>()?;
let mut result = Vec::new();
for item in py_list.iter() {
result.push(item.extract()?);
}
Ok(result)
}
// Nested lists
fn extract_nested_list(pred: &PyAny) -> PyResult<Vec<Vec<String>>> {
let nested: Vec<Vec<String>> = pred.getattr("nested_data")?.extract()?;
Ok(nested)
}
Dict/HashMap mapping:
| Python Type | Rust Type | Example |
|---|---|---|
Dict[str, str] |
HashMap<String, String> |
extract::<HashMap<_, _>>() |
Dict[str, int] |
HashMap<String, i64> |
String keys, int values |
Dict[str, Any] |
HashMap<String, Py<PyAny>> |
Heterogeneous values |
Example: Dict extraction:
use pyo3::prelude::*;
use pyo3::types::PyDict;
use std::collections::HashMap;
fn extract_dict(pred: &PyAny) -> PyResult<HashMap<String, String>> {
// Method 1: Direct extraction
let metadata: HashMap<String, String> = pred.getattr("metadata")?.extract()?;
// Method 2: Manual iteration with validation
let py_dict = pred.getattr("metadata")?.downcast::<PyDict>()?;
let mut result = HashMap::new();
for (key, value) in py_dict.iter() {
let k: String = key.extract()?;
let v: String = value.extract()?;
result.insert(k, v);
}
Ok(result)
}
// Mixed value types
fn extract_heterogeneous_dict(py: Python, pred: &PyAny) -> PyResult<HashMap<String, Py<PyAny>>> {
let py_dict = pred.getattr("data")?.downcast::<PyDict>()?;
let mut result = HashMap::new();
for (key, value) in py_dict.iter() {
let k: String = key.extract()?;
result.insert(k, value.into());
}
Ok(result)
}
Tuple mapping:
| Python Type | Rust Type | Example |
|---|---|---|
Tuple[str, int] |
(String, i64) |
Fixed-size tuple |
Tuple[str, str, float] |
(String, String, f64) |
Three elements |
Example: Tuple extraction:
use pyo3::prelude::*;
fn extract_tuple(pred: &PyAny) -> PyResult<(String, i64, f64)> {
// Direct extraction
let tuple: (String, i64, f64) = pred.getattr("coordinates")?.extract()?;
Ok(tuple)
}
// Variable-length tuples as Vec
fn extract_var_tuple(pred: &PyAny) -> PyResult<Vec<String>> {
// Python tuple -> Rust Vec for variable length
let items: Vec<String> = pred.getattr("items")?.extract()?;
Ok(items)
}
Set/HashSet mapping:
use pyo3::prelude::*;
use std::collections::HashSet;
fn extract_set(pred: &PyAny) -> PyResult<HashSet<String>> {
let tags: HashSet<String> = pred.getattr("tags")?.extract()?;
Ok(tags)
}
Optional Types
Option mapping:
| Python Type | Rust Type | Handling |
|---|---|---|
Optional[str] |
Option<String> |
May be None |
Optional[int] |
Option<i64> |
May be None |
str | None |
Option<String> |
Union syntax |
Example: Safe optional extraction:
use pyo3::prelude::*;
fn extract_optional(pred: &PyAny) -> PyResult<Option<String>> {
// Method 1: ok() + and_then (recommended)
let reasoning: Option<String> = pred
.getattr("reasoning")
.ok()
.and_then(|attr| attr.extract().ok());
Ok(reasoning)
}
// Multiple optional fields
#[derive(Debug)]
struct OptionalFields {
required: String,
optional_str: Option<String>,
optional_int: Option<i64>,
optional_float: Option<f64>,
}
impl OptionalFields {
fn from_prediction(pred: &PyAny) -> PyResult<Self> {
Ok(Self {
required: pred.getattr("required")?.extract()?,
optional_str: pred.getattr("optional_str")
.ok()
.and_then(|a| a.extract().ok()),
optional_int: pred.getattr("optional_int")
.ok()
.and_then(|a| a.extract().ok()),
optional_float: pred.getattr("optional_float")
.ok()
.and_then(|a| a.extract().ok()),
})
}
}
Default values pattern:
use pyo3::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ConfigWithDefaults {
pub model: String,
#[serde(default = "default_temperature")]
pub temperature: f32,
#[serde(default = "default_max_tokens")]
pub max_tokens: usize,
#[serde(default)]
pub system_prompt: Option<String>,
}
fn default_temperature() -> f32 { 0.7 }
fn default_max_tokens() -> usize { 1000 }
impl ConfigWithDefaults {
fn from_prediction(pred: &PyAny) -> PyResult<Self> {
Ok(Self {
model: pred.getattr("model")?.extract()?,
temperature: pred.getattr("temperature")
.ok()
.and_then(|a| a.extract().ok())
.unwrap_or_else(default_temperature),
max_tokens: pred.getattr("max_tokens")
.ok()
.and_then(|a| a.extract().ok())
.unwrap_or_else(default_max_tokens),
system_prompt: pred.getattr("system_prompt")
.ok()
.and_then(|a| a.extract().ok()),
})
}
}
Complex Types
serde_json::Value for dynamic types:
use pyo3::prelude::*;
use serde_json::Value;
fn extract_dynamic(pred: &PyAny) -> PyResult<Value> {
// Convert Python object to JSON
let json_module = pred.py().import("json")?;
let json_str: String = json_module
.getattr("dumps")?
.call1((pred,))?
.extract()?;
let value: Value = serde_json::from_str(&json_str)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(
format!("JSON parse error: {}", e)
))?;
Ok(value)
}
Type decision tree:
Is field always present?
├─ Yes: Use T
└─ No: Use Option<T>
Is field type known?
├─ Yes: Use specific type (String, i64, etc.)
└─ No: Use serde_json::Value
Is field a collection?
├─ List: Use Vec<T>
├─ Dict: Use HashMap<K, V>
├─ Set: Use HashSet<T>
└─ Tuple: Use (T1, T2, ...)
Does field need validation?
├─ Yes: Use Pydantic
└─ No: Direct extraction
Signature Extraction
Basic Signature Parsing
DSPy signature format:
"input1, input2, input3 -> output1, output2"
Example: Manual signature parsing:
use pyo3::prelude::*;
use pyo3::types::PyDict;
#[derive(Debug)]
struct Signature {
input_fields: Vec<String>,
output_fields: Vec<String>,
}
impl Signature {
fn parse(signature: &str) -> Option<Self> {
let parts: Vec<&str> = signature.split("->").collect();
if parts.len() != 2 {
return None;
}
let input_fields = parts[0]
.split(',')
.map(|s| s.trim().to_string())
.collect();
let output_fields = parts[1]
.split(',')
.map(|s| s.trim().to_string())
.collect();
Some(Self { input_fields, output_fields })
}
}
// Usage
fn extract_signature_fields(py: Python, signature: &str) -> PyResult<()> {
let sig = Signature::parse(signature)
.ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>(
"Invalid signature format"
))?;
println!("Inputs: {:?}", sig.input_fields);
println!("Outputs: {:?}", sig.output_fields);
Ok(())
}
Introspecting DSPy Modules
Extract signature from module instance:
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList};
fn inspect_module_signature(py: Python, module: &PyAny) -> PyResult<()> {
// Get signature attribute
let signature = module.getattr("signature")?;
// Extract signature string
let sig_str: String = signature.to_string();
println!("Signature: {}", sig_str);
// Get input fields if available
if let Ok(input_fields) = signature.getattr("input_fields") {
let inputs = input_fields.downcast::<PyDict>()?;
println!("\nInput fields:");
for (key, value) in inputs.iter() {
let field_name: String = key.extract()?;
println!(" - {}", field_name);
// Try to get type annotation
if let Ok(annotation) = value.getattr("annotation") {
let type_str: String = annotation.to_string();
println!(" Type: {}", type_str);
}
}
}
// Get output fields
if let Ok(output_fields) = signature.getattr("output_fields") {
let outputs = output_fields.downcast::<PyDict>()?;
println!("\nOutput fields:");
for (key, _) in outputs.iter() {
let field_name: String = key.extract()?;
println!(" - {}", field_name);
}
}
Ok(())
}
Field Type Extraction
Using module_inspector.py for automated extraction:
use pyo3::prelude::*;
use pyo3::types::PyModule as PyMod;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Deserialize)]
struct FieldInfo {
name: String,
python_type: String,
is_input: bool,
is_output: bool,
description: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ModuleFields {
inputs: Vec<FieldInfo>,
outputs: Vec<FieldInfo>,
}
fn extract_fields_with_inspector(
py: Python,
module_name: &str
) -> PyResult<ModuleFields> {
// Load module_inspector.py
let inspector_code = include_str!("../../pyo3-dspy-fundamentals/resources/scripts/module_inspector.py");
let inspector_module = PyMod::from_code(
py,
inspector_code,
"module_inspector.py",
"module_inspector"
)?;
// Create inspector instance
let inspector_class = inspector_module.getattr("ModuleInspector")?;
let inspector = inspector_class.call0()?;
// Import target module
let target_module = py.import(module_name)?;
// Inspect module
let info = inspector.call_method1("inspect_from_object", (target_module,))?;
// Extract fields as JSON
let json_module = py.import("json")?;
let fields_dict = info.getattr("input_fields")?;
let json_str: String = json_module
.getattr("dumps")?
.call1((fields_dict,))?
.extract()?;
// Parse to Rust
let fields: ModuleFields = serde_json::from_str(&json_str)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(
format!("JSON parse error: {}", e)
))?;
Ok(fields)
}
Type Annotation Parsing
Pattern matching for complex types:
#[derive(Debug, Clone)]
enum PythonType {
Str,
Int,
Float,
Bool,
List(Box<PythonType>),
Dict(Box<PythonType>, Box<PythonType>),
Optional(Box<PythonType>),
Tuple(Vec<PythonType>),
Any,
}
impl PythonType {
fn parse(type_str: &str) -> Self {
let type_str = type_str.trim();
// Basic types
match type_str {
"str" => Self::Str,
"int" => Self::Int,
"float" => Self::Float,
"bool" => Self::Bool,
"Any" => Self::Any,
_ => {
// Complex types
if type_str.starts_with("Optional[") {
let inner = &type_str[9..type_str.len()-1];
Self::Optional(Box::new(Self::parse(inner)))
} else if type_str.starts_with("List[") {
let inner = &type_str[5..type_str.len()-1];
Self::List(Box::new(Self::parse(inner)))
} else if type_str.starts_with("Dict[") {
let inner = &type_str[5..type_str.len()-1];
let parts: Vec<&str> = inner.split(',').collect();
if parts.len() == 2 {
Self::Dict(
Box::new(Self::parse(parts[0].trim())),
Box::new(Self::parse(parts[1].trim()))
)
} else {
Self::Any
}
} else {
Self::Any
}
}
}
}
fn to_rust_type(&self) -> String {
match self {
Self::Str => "String".to_string(),
Self::Int => "i64".to_string(),
Self::Float => "f64".to_string(),
Self::Bool => "bool".to_string(),
Self::List(inner) => format!("Vec<{}>", inner.to_rust_type()),
Self::Dict(key, val) => {
format!("HashMap<{}, {}>", key.to_rust_type(), val.to_rust_type())
}
Self::Optional(inner) => format!("Option<{}>", inner.to_rust_type()),
Self::Tuple(types) => {
let rust_types: Vec<String> = types.iter()
.map(|t| t.to_rust_type())
.collect();
format!("({})", rust_types.join(", "))
}
Self::Any => "serde_json::Value".to_string(),
}
}
}
// Usage
fn demonstrate_type_parsing() {
let type_str = "Optional[List[str]]";
let py_type = PythonType::parse(type_str);
let rust_type = py_type.to_rust_type();
println!("{} -> {}", type_str, rust_type);
// Output: Optional[List[str]] -> Option<Vec<String>>
}
Pydantic Integration
Basic Pydantic Validation
Rust struct with Pydantic validation:
use pyo3::prelude::*;
use pyo3::types::PyModule as PyMod;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct User {
username: String,
email: String,
age: i32,
}
const USER_MODEL: &str = r#"
from pydantic import BaseModel, EmailStr, Field
class User(BaseModel):
username: str = Field(min_length=3, max_length=50)
email: EmailStr
age: int = Field(ge=0, le=150)
"#;
impl User {
fn validate(py: Python, data: &str) -> PyResult<Self> {
// Import Pydantic model
let model_module = PyMod::from_code(py, USER_MODEL, "", "")?;
let user_class = model_module.getattr("User")?;
// Parse JSON
let json_module = py.import("json")?;
let parsed_data = json_module.getattr("loads")?.call1((data,))?;
// Validate with Pydantic (will raise if invalid)
let user_obj = user_class.call1((parsed_data,))?;
// Extract to Rust
Ok(Self {
username: user_obj.getattr("username")?.extract()?,
email: user_obj.getattr("email")?.extract()?,
age: user_obj.getattr("age")?.extract()?,
})
}
fn to_pydantic_json(&self, py: Python) -> PyResult<String> {
// Import model
let model_module = PyMod::from_code(py, USER_MODEL, "", "")?;
let user_class = model_module.getattr("User")?;
// Create instance
let kwargs = pyo3::types::PyDict::new(py);
kwargs.set_item("username", &self.username)?;
kwargs.set_item("email", &self.email)?;
kwargs.set_item("age", self.age)?;
let user_obj = user_class.call((), Some(kwargs))?;
// Serialize to JSON
let json_str: String = user_obj.call_method0("model_dump_json")?.extract()?;
Ok(json_str)
}
}
Pydantic with DSPy
DSPy module returning Pydantic-validated data:
# dspy_pydantic_module.py
import dspy
from pydantic import BaseModel, Field
from typing import List
class Answer(BaseModel):
"""Structured answer with validation."""
text: str = Field(min_length=10)
confidence: float = Field(ge=0.0, le=1.0)
sources: List[str] = Field(default_factory=list)
class QAWithValidation(dspy.Module):
def __init__(self):
super().__init__()
self.generate = dspy.ChainOfThought("question -> answer_json")
def forward(self, question: str) -> Answer:
result = self.generate(question=question)
# Parse and validate
answer = Answer.model_validate_json(result.answer_json)
return answer
Rust integration:
use pyo3::prelude::*;
use pyo3::types::PyModule as PyMod;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Answer {
text: String,
confidence: f64,
sources: Vec<String>,
}
struct QAWithValidation {
instance: Py<PyAny>,
}
impl QAWithValidation {
fn new(py: Python) -> PyResult<Self> {
let code = include_str!("dspy_pydantic_module.py");
let module = PyMod::from_code(py, code, "dspy_pydantic_module.py", "dspy_pydantic_module")?;
let class = module.getattr("QAWithValidation")?;
let instance = class.call0()?;
Ok(Self {
instance: instance.into(),
})
}
fn ask(&self, py: Python, question: &str) -> PyResult<Answer> {
// Call forward method - Pydantic validates in Python
let result = self.instance.as_ref(py).call_method1("forward", (question,))?;
// Extract validated data
Ok(Answer {
text: result.getattr("text")?.extract()?,
confidence: result.getattr("confidence")?.extract()?,
sources: result.getattr("sources")?.extract()?,
})
}
}
Validation Error Handling
Catching and converting Pydantic errors:
use pyo3::prelude::*;
use thiserror::Error;
#[derive(Debug, Error)]
enum ValidationError {
#[error("Pydantic validation failed: {message}")]
PydanticError { message: String, errors: Vec<String> },
#[error("Python error: {0}")]
Python(#[from] PyErr),
}
fn validate_with_pydantic(
py: Python,
model_code: &str,
class_name: &str,
data_json: &str,
) -> Result<Py<PyAny>, ValidationError> {
// Import model
let module = PyModule::from_code(py, model_code, "", "")
.map_err(|e| ValidationError::Python(e))?;
let model_class = module.getattr(class_name)
.map_err(|e| ValidationError::Python(e))?;
// Parse JSON
let json_module = py.import("json")
.map_err(|e| ValidationError::Python(e))?;
let data = json_module.getattr("loads")?.call1((data_json,))?;
// Try to validate
match model_class.call1((data,)) {
Ok(instance) => Ok(instance.into()),
Err(e) => {
// Extract Pydantic validation errors
let err_str = e.value(py).to_string();
// Try to get structured errors
let mut errors = Vec::new();
if let Ok(pydantic_err) = e.value(py).getattr("errors") {
if let Ok(err_list) = pydantic_err.call0() {
// Parse error list
for item in err_list.iter()? {
if let Ok(err_dict) = item.downcast::<pyo3::types::PyDict>() {
if let Ok(msg) = err_dict.get_item("msg") {
errors.push(msg.to_string());
}
}
}
}
}
Err(ValidationError::PydanticError {
message: err_str,
errors,
})
}
}
}
Bidirectional Conversion
Rust ↔ Pydantic round-trip:
use pyo3::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Product {
name: String,
price: f64,
quantity: i32,
}
const PRODUCT_MODEL: &str = r#"
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(min_length=1)
price: float = Field(gt=0)
quantity: int = Field(ge=0)
"#;
impl Product {
// Rust -> Pydantic -> JSON
fn to_validated_json(&self, py: Python) -> PyResult<String> {
let module = PyModule::from_code(py, PRODUCT_MODEL, "", "")?;
let product_class = module.getattr("Product")?;
// Create Pydantic instance (validates)
let kwargs = pyo3::types::PyDict::new(py);
kwargs.set_item("name", &self.name)?;
kwargs.set_item("price", self.price)?;
kwargs.set_item("quantity", self.quantity)?;
let instance = product_class.call((), Some(kwargs))?;
let json: String = instance.call_method0("model_dump_json")?.extract()?;
Ok(json)
}
// JSON -> Pydantic -> Rust
fn from_validated_json(py: Python, json: &str) -> PyResult<Self> {
let module = PyModule::from_code(py, PRODUCT_MODEL, "", "")?;
let product_class = module.getattr("Product")?;
// Validate with Pydantic
let instance = product_class.call_method1("model_validate_json", (json,))?;
// Extract to Rust
Ok(Self {
name: instance.getattr("name")?.extract()?,
price: instance.getattr("price")?.extract()?,
quantity: instance.getattr("quantity")?.extract()?,
})
}
// Test round-trip
fn test_roundtrip(&self, py: Python) -> PyResult<bool> {
let json = self.to_validated_json(py)?;
let restored = Self::from_validated_json(py, &json)?;
Ok(self.name == restored.name
&& (self.price - restored.price).abs() < 0.01
&& self.quantity == restored.quantity)
}
}
Custom Type Converters
ToKwargs Trait
Generic trait for converting to Python kwargs:
use pyo3::prelude::*;
use pyo3::types::PyDict;
pub trait ToKwargs {
fn to_kwargs(&self, py: Python) -> PyResult<&PyDict>;
}
// Example implementation
#[derive(Debug, Clone)]
struct QueryInput {
question: String,
context: Option<String>,
max_tokens: usize,
}
impl ToKwargs for QueryInput {
fn to_kwargs(&self, py: Python) -> PyResult<&PyDict> {
let dict = PyDict::new(py);
dict.set_item("question", &self.question)?;
if let Some(ref ctx) = self.context {
dict.set_item("context", ctx)?;
}
dict.set_item("max_tokens", self.max_tokens)?;
Ok(dict)
}
}
FromPrediction Trait
Generic trait for extracting from predictions:
use pyo3::prelude::*;
pub trait FromPrediction: Sized {
fn from_prediction(pred: &PyAny) -> PyResult<Self>;
}
// Example implementation
#[derive(Debug, Clone)]
struct QueryOutput {
answer: String,
reasoning: Option<String>,
confidence: f64,
}
impl FromPrediction for QueryOutput {
fn from_prediction(pred: &PyAny) -> PyResult<Self> {
Ok(Self {
answer: pred.getattr("answer")?.extract()?,
reasoning: pred.getattr("reasoning")
.ok()
.and_then(|r| r.extract().ok()),
confidence: pred.getattr("confidence")
.ok()
.and_then(|c| c.extract().ok())
.unwrap_or(0.0),
})
}
}
Complex Document Type
Custom converter for document with metadata:
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Document {
pub id: String,
pub content: String,
pub metadata: HashMap<String, String>,
pub embedding: Option<Vec<f32>>,
pub score: f64,
}
impl ToKwargs for Document {
fn to_kwargs(&self, py: Python) -> PyResult<&PyDict> {
let dict = PyDict::new(py);
dict.set_item("id", &self.id)?;
dict.set_item("content", &self.content)?;
// Convert metadata
let meta_dict = PyDict::new(py);
for (k, v) in &self.metadata {
meta_dict.set_item(k, v)?;
}
dict.set_item("metadata", meta_dict)?;
// Convert embedding if present
if let Some(ref emb) = self.embedding {
dict.set_item("embedding", emb)?;
}
dict.set_item("score", self.score)?;
Ok(dict)
}
}
impl FromPrediction for Document {
fn from_prediction(pred: &PyAny) -> PyResult<Self> {
// Extract required fields
let id: String = pred.getattr("id")?.extract()?;
let content: String = pred.getattr("content")?.extract()?;
let score: f64 = pred.getattr("score")?.extract()?;
// Extract metadata dict
let meta_dict = pred.getattr("metadata")?.downcast::<PyDict>()?;
let mut metadata = HashMap::new();
for (k, v) in meta_dict.iter() {
metadata.insert(k.extract()?, v.extract()?);
}
// Extract optional embedding
let embedding: Option<Vec<f32>> = pred.getattr("embedding")
.ok()
.and_then(|e| e.extract().ok());
Ok(Self {
id,
content,
metadata,
embedding,
score,
})
}
}
// Convert list of documents
impl Document {
pub fn vec_from_prediction(pred: &PyAny, field: &str) -> PyResult<Vec<Self>> {
let docs_list = pred.getattr(field)?.downcast::<PyList>()?;
let mut result = Vec::new();
for item in docs_list.iter() {
result.push(Self::from_prediction(item)?);
}
Ok(result)
}
pub fn vec_to_kwargs(docs: &[Self], py: Python) -> PyResult<Py<PyAny>> {
let list = PyList::empty(py);
for doc in docs {
let dict = doc.to_kwargs(py)?;
list.append(dict)?;
}
Ok(list.into())
}
}
Enum Converters
Converting between Rust enums and Python strings:
use pyo3::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ModelProvider {
OpenAI,
Anthropic,
Cohere,
Together,
}
impl ModelProvider {
pub fn to_py_str(&self) -> &str {
match self {
Self::OpenAI => "openai",
Self::Anthropic => "anthropic",
Self::Cohere => "cohere",
Self::Together => "together",
}
}
pub fn from_py_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"openai" => Some(Self::OpenAI),
"anthropic" => Some(Self::Anthropic),
"cohere" => Some(Self::Cohere),
"together" => Some(Self::Together),
_ => None,
}
}
}
impl ToPyObject for ModelProvider {
fn to_object(&self, py: Python) -> PyObject {
self.to_py_str().to_object(py)
}
}
impl<'source> FromPyObject<'source> for ModelProvider {
fn extract(ob: &'source PyAny) -> PyResult<Self> {
let s: String = ob.extract()?;
Self::from_py_str(&s)
.ok_or_else(|| PyErr::new::<pyo3::exceptions::PyValueError, _>(
format!("Invalid provider: {}", s)
))
}
}
Generic Wrappers
TypedPredictor
Generic type-safe predictor wrapper:
use pyo3::prelude::*;
use std::marker::PhantomData;
pub struct TypedPredictor<I, O> {
predictor: Py<PyAny>,
signature: String,
_input: PhantomData<I>,
_output: PhantomData<O>,
}
impl<I, O> TypedPredictor<I, O>
where
I: ToKwargs,
O: FromPrediction,
{
pub fn new(py: Python, module_type: &str, signature: &str) -> PyResult<Self> {
let dspy = PyModule::import(py, "dspy")?;
let predictor_class = dspy.getattr(module_type)?;
let predictor = predictor_class.call1(((signature,),))?;
Ok(Self {
predictor: predictor.into(),
signature: signature.to_string(),
_input: PhantomData,
_output: PhantomData,
})
}
pub fn predict(&self, py: Python, input: &I) -> PyResult<O> {
let kwargs = input.to_kwargs(py)?;
let result = self.predictor.as_ref(py).call((), Some(kwargs))?;
O::from_prediction(result)
}
pub fn signature(&self) -> &str {
&self.signature
}
}
// Example usage
fn typed_predictor_example() -> PyResult<()> {
Python::with_gil(|py| {
// Create typed predictor
let predictor: TypedPredictor<QueryInput, QueryOutput> =
TypedPredictor::new(
py,
"ChainOfThought",
"question, context -> answer, reasoning, confidence"
)?;
// Type-safe prediction
let input = QueryInput {
question: "What is Rust?".to_string(),
context: Some("Rust is a systems language".to_string()),
max_tokens: 1000,
};
let output: QueryOutput = predictor.predict(py, &input)?;
println!("Answer: {}", output.answer);
if let Some(reasoning) = output.reasoning {
println!("Reasoning: {}", reasoning);
}
println!("Confidence: {}", output.confidence);
Ok(())
})
}
TypedModule
Generic wrapper for custom DSPy modules:
use pyo3::prelude::*;
use std::marker::PhantomData;
pub struct TypedModule<I, O> {
instance: Py<PyAny>,
_input: PhantomData<I>,
_output: PhantomData<O>,
}
impl<I, O> TypedModule<I, O>
where
I: ToKwargs,
O: FromPrediction,
{
pub fn from_python_code(
py: Python,
code: &str,
class_name: &str,
) -> PyResult<Self> {
let module = PyModule::from_code(py, code, "module.py", "module")?;
let class = module.getattr(class_name)?;
let instance = class.call0()?;
Ok(Self {
instance: instance.into(),
_input: PhantomData,
_output: PhantomData,
})
}
pub fn from_file(
py: Python,
path: &str,
class_name: &str,
) -> PyResult<Self> {
let code = std::fs::read_to_string(path)?;
Self::from_python_code(py, &code, class_name)
}
pub fn forward(&self, py: Python, input: &I) -> PyResult<O> {
let kwargs = input.to_kwargs(py)?;
let result = self.instance.as_ref(py).call_method("forward", (), Some(kwargs))?;
O::from_prediction(result)
}
pub fn __call__(&self, py: Python, input: &I) -> PyResult<O> {
self.forward(py, input)
}
}
// Example usage
const QA_MODULE: &str = r#"
import dspy
class QAModule(dspy.Module):
def __init__(self):
super().__init__()
self.generate = dspy.ChainOfThought("question, context -> answer, confidence")
def forward(self, question, context):
return self.generate(question=question, context=context)
"#;
fn typed_module_example() -> PyResult<()> {
Python::with_gil(|py| {
let module: TypedModule<QueryInput, QueryOutput> =
TypedModule::from_python_code(py, QA_MODULE, "QAModule")?;
let input = QueryInput {
question: "What is DSPy?".to_string(),
context: Some("DSPy is a framework".to_string()),
max_tokens: 500,
};
let output = module.forward(py, &input)?;
println!("Answer: {}", output.answer);
Ok(())
})
}
Builder Pattern
Type-safe builder for predictors:
use pyo3::prelude::*;
pub struct PredictorBuilder<I, O> {
module_type: String,
signature: String,
_marker: PhantomData<(I, O)>,
}
impl<I, O> PredictorBuilder<I, O> {
pub fn new(signature: &str) -> Self {
Self {
module_type: "Predict".to_string(),
signature: signature.to_string(),
_marker: PhantomData,
}
}
pub fn with_chain_of_thought(mut self) -> Self {
self.module_type = "ChainOfThought".to_string();
self
}
pub fn with_program_of_thought(mut self) -> Self {
self.module_type = "ProgramOfThought".to_string();
self
}
pub fn with_react(mut self) -> Self {
self.module_type = "ReAct".to_string();
self
}
pub fn build(self, py: Python) -> PyResult<TypedPredictor<I, O>>
where
I: ToKwargs,
O: FromPrediction,
{
TypedPredictor::new(py, &self.module_type, &self.signature)
}
}
// Usage
fn builder_example() -> PyResult<()> {
Python::with_gil(|py| {
let predictor = PredictorBuilder::<QueryInput, QueryOutput>::new(
"question -> answer, confidence"
)
.with_chain_of_thought()
.build(py)?;
// Use predictor...
Ok(())
})
}
Error Handling
Comprehensive Error Types
use pyo3::prelude::*;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TypeConversionError {
#[error("Missing required field: {field}")]
MissingField {
field: String,
},
#[error("Type mismatch for field '{field}': expected {expected}, got {actual}")]
TypeMismatch {
field: String,
expected: String,
actual: String,
},
#[error("Validation failed for field '{field}': {message}")]
ValidationFailed {
field: String,
message: String,
},
#[error("List extraction failed: {0}")]
ListError(String),
#[error("Dict extraction failed: {0}")]
DictError(String),
#[error("Pydantic validation error: {0}")]
PydanticError(String),
#[error("Python runtime error: {0}")]
PythonError(#[from] PyErr),
#[error("JSON serialization error: {0}")]
JsonError(#[from] serde_json::Error),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, TypeConversionError>;
Validated Extraction
Safe extraction with detailed errors:
pub trait ValidatedExtract: Sized {
fn extract_validated(obj: &PyAny, field: &str) -> Result<Self>;
}
impl ValidatedExtract for String {
fn extract_validated(obj: &PyAny, field: &str) -> Result<Self> {
let attr = obj.getattr(field)
.map_err(|_| TypeConversionError::MissingField {
field: field.to_string(),
})?;
// Check type
let type_name = attr.get_type().name()
.map_err(|e| TypeConversionError::PythonError(e))?;
if type_name != "str" {
return Err(TypeConversionError::TypeMismatch {
field: field.to_string(),
expected: "str".to_string(),
actual: type_name.to_string(),
});
}
attr.extract()
.map_err(|e| TypeConversionError::PythonError(e))
}
}
impl ValidatedExtract for f64 {
fn extract_validated(obj: &PyAny, field: &str) -> Result<Self> {
let attr = obj.getattr(field)
.map_err(|_| TypeConversionError::MissingField {
field: field.to_string(),
})?;
attr.extract()
.map_err(|_| TypeConversionError::TypeMismatch {
field: field.to_string(),
expected: "float".to_string(),
actual: attr.get_type().name().unwrap_or("unknown").to_string(),
})
}
}
impl<T: ValidatedExtract> ValidatedExtract for Option<T> {
fn extract_validated(obj: &PyAny, field: &str) -> Result<Self> {
match T::extract_validated(obj, field) {
Ok(val) => Ok(Some(val)),
Err(TypeConversionError::MissingField { .. }) => Ok(None),
Err(e) => Err(e),
}
}
}
impl<T: ValidatedExtract> ValidatedExtract for Vec<T> {
fn extract_validated(obj: &PyAny, field: &str) -> Result<Self> {
let attr = obj.getattr(field)
.map_err(|_| TypeConversionError::MissingField {
field: field.to_string(),
})?;
let list = attr.downcast::<pyo3::types::PyList>()
.map_err(|_| TypeConversionError::TypeMismatch {
field: field.to_string(),
expected: "list".to_string(),
actual: attr.get_type().name().unwrap_or("unknown").to_string(),
})?;
let mut result = Vec::new();
for (i, item) in list.iter().enumerate() {
// Create synthetic field name for error reporting
let item_field = format!("{}[{}]", field, i);
// We can't use ValidatedExtract directly on items, so use extract
let val: T = item.extract()
.map_err(|e| TypeConversionError::ListError(
format!("{}: {}", item_field, e)
))?;
result.push(val);
}
Ok(result)
}
}
Error Recovery
Strategies for handling type errors:
pub struct ExtractionStrategy;
impl ExtractionStrategy {
/// Try multiple extraction strategies in order
pub fn try_extract_string(obj: &PyAny, field: &str) -> Result<String> {
// Strategy 1: Direct extraction
if let Ok(s) = String::extract_validated(obj, field) {
return Ok(s);
}
// Strategy 2: Try to_string()
if let Ok(attr) = obj.getattr(field) {
if let Ok(s) = attr.str() {
return Ok(s.to_string());
}
}
// Strategy 3: Check if it's bytes and decode
if let Ok(attr) = obj.getattr(field) {
if let Ok(bytes) = attr.extract::<Vec<u8>>() {
if let Ok(s) = String::from_utf8(bytes) {
return Ok(s);
}
}
}
Err(TypeConversionError::TypeMismatch {
field: field.to_string(),
expected: "str".to_string(),
actual: "unknown".to_string(),
})
}
/// Extract with default fallback
pub fn extract_or_default<T>(obj: &P
…(truncated)