use std::path::PathBuf;
use bytes::Bytes;
use pyo3::prelude::*;
use pyo3::types::PyTuple;
use crate::conversion::python_headers_to_rust;
use crate::errors::map_err;
use eggfetch_core::multipart::{Multipart, Part, PartBody};
#[pyclass(name = "File")]
#[derive(Debug, Clone)]
pub struct PyFile {
path: PathBuf,
filename: String,
content_type: String,
}
#[pymethods]
impl PyFile {
#[new]
#[pyo3(signature = (path, *, filename=None, content_type=None))]
fn new(
path: &Bound<'_, PyAny>,
filename: Option<&str>,
content_type: Option<&str>,
) -> PyResult<Self> {
let path_str: String = path.extract().map_err(|_| {
PyErr::new::<pyo3::exceptions::PyTypeError, _>(
"path must be a string or path-like object",
)
})?;
let pb = PathBuf::from(&path_str);
let default_filename = pb
.file_name()
.map_or_else(|| "file".to_owned(), |n| n.to_string_lossy().into_owned());
Ok(Self {
path: pb,
filename: filename.unwrap_or(&default_filename).to_owned(),
content_type: content_type
.unwrap_or("application/octet-stream")
.to_owned(),
})
}
#[getter]
fn path(&self) -> String {
self.path.to_string_lossy().into_owned()
}
#[getter]
fn filename(&self) -> &str {
&self.filename
}
#[getter]
fn content_type(&self) -> &str {
&self.content_type
}
fn __repr__(&self) -> String {
format!(
"File(path={:?}, filename={:?}, content_type={:?})",
self.path.display(),
self.filename,
self.content_type
)
}
}
pub fn build_multipart_body<'py>(
py: Python<'py>,
data: Option<&Bound<'py, PyAny>>,
files: &Bound<'py, PyAny>,
) -> PyResult<(eggfetch_core::RequestBody, String)> {
let mut multipart = Multipart::new();
let boundary = multipart.boundary().clone();
if let Some(d) = data {
add_form_fields(&mut multipart, py, d)?;
}
add_file_parts(&mut multipart, py, files)?;
let ct = format!("multipart/form-data; boundary={boundary}");
let body = multipart.into_body();
Ok((body, ct))
}
fn add_form_fields(
multipart: &mut Multipart,
_py: Python<'_>,
data: &Bound<'_, PyAny>,
) -> PyResult<()> {
let items = if data.get_type().hasattr("__getitem__")? && data.hasattr("items")? {
data.call_method0("items")?
} else {
data.clone()
};
for item in items.try_iter()? {
let item = item?;
let tuple: Bound<'_, PyTuple> = item.downcast_into::<PyTuple>()?;
if tuple.len() != 2 {
return Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(
"expected a mapping or sequence of 2-tuples",
));
}
let key: String = tuple.get_item(0)?.extract()?;
let value: String = tuple.get_item(1)?.extract()?;
*multipart = std::mem::replace(multipart, Multipart::new())
.text(&key, &value)
.map_err(map_err)?;
}
Ok(())
}
fn add_file_parts(
multipart: &mut Multipart,
py: Python<'_>,
files: &Bound<'_, PyAny>,
) -> PyResult<()> {
let pairs = if files.get_type().hasattr("__getitem__")? && files.hasattr("items")? {
let items = files.call_method0("items")?;
let mut pairs = Vec::new();
for item in items.try_iter()? {
let item = item?;
let tuple: Bound<'_, PyTuple> = item.downcast_into::<PyTuple>()?;
let field_name: String = tuple.get_item(0)?.extract()?;
let file_spec = tuple.get_item(1)?;
pairs.push((field_name, file_spec));
}
pairs
} else {
let mut pairs = Vec::new();
for item in files.try_iter()? {
let item = item?;
let tuple: Bound<'_, PyTuple> = item.downcast_into::<PyTuple>()?;
if tuple.len() != 2 {
return Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(
"files sequence items must be 2-tuples of (field_name, file_spec)",
));
}
let field_name: String = tuple.get_item(0)?.extract()?;
let file_spec = tuple.get_item(1)?;
pairs.push((field_name, file_spec));
}
pairs
};
for (field_name, file_spec) in pairs {
add_single_file_part(multipart, py, &field_name, &file_spec)?;
}
Ok(())
}
fn add_single_file_part(
multipart: &mut Multipart,
py: Python<'_>,
field_name: &str,
file_spec: &Bound<'_, PyAny>,
) -> PyResult<()> {
if let Ok(py_file) = file_spec.extract::<PyFile>() {
return add_path_file_part(multipart, field_name, &py_file);
}
if let Ok(b) = file_spec.extract::<Vec<u8>>() {
let filename = field_name.to_owned();
*multipart = std::mem::replace(multipart, Multipart::new())
.bytes(
field_name,
&filename,
"application/octet-stream",
Bytes::from(b),
)
.map_err(map_err)?;
return Ok(());
}
if let Ok(s) = file_spec.extract::<String>() {
let filename = field_name.to_owned();
*multipart = std::mem::replace(multipart, Multipart::new())
.bytes(
field_name,
&filename,
"text/plain",
Bytes::from(s.into_bytes()),
)
.map_err(map_err)?;
return Ok(());
}
if let Ok(tuple) = file_spec.downcast::<PyTuple>() {
return add_tuple_file_part(multipart, py, field_name, tuple);
}
Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!(
"unsupported file spec type for field {field_name}: {}",
file_spec.get_type().name()?
)))
}
fn add_tuple_file_part(
multipart: &mut Multipart,
py: Python<'_>,
field_name: &str,
tuple: &Bound<'_, PyTuple>,
) -> PyResult<()> {
let len = tuple.len();
if !(2..=4).contains(&len) {
return Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(
"file tuple must be (filename, data), (filename, data, content_type), or (filename, data, content_type, headers)",
));
}
let filename: String = tuple.get_item(0)?.extract()?;
let data_bytes = extract_data_bytes(&tuple.get_item(1)?)?;
let content_type: String = if len >= 3 {
tuple.get_item(2)?.extract()?
} else {
guess_content_type(&filename)
};
if len >= 4 {
let headers_obj = tuple.get_item(3)?;
if !headers_obj.is_none() {
let headers = python_headers_to_rust(py, &headers_obj)?;
let mut part =
Part::new(field_name, PartBody::Bytes(data_bytes))
.filename(&filename)
.content_type(http::HeaderValue::from_str(&content_type).map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string())
})?);
for (name, value) in headers.iter() {
part = part.header(name.as_str(), value.clone());
}
*multipart = std::mem::replace(multipart, Multipart::new())
.part(part)
.map_err(map_err)?;
return Ok(());
}
}
*multipart = std::mem::replace(multipart, Multipart::new())
.bytes(field_name, &filename, &content_type, data_bytes)
.map_err(map_err)?;
Ok(())
}
fn add_path_file_part(
multipart: &mut Multipart,
field_name: &str,
py_file: &PyFile,
) -> PyResult<()> {
let data = std::fs::read(&py_file.path).map_err(|e| {
PyErr::new::<pyo3::exceptions::PyFileExistsError, _>(format!(
"failed to read {}: {e}",
py_file.path.display()
))
})?;
*multipart = std::mem::replace(multipart, Multipart::new())
.bytes(
field_name,
&py_file.filename,
&py_file.content_type,
Bytes::from(data),
)
.map_err(map_err)?;
Ok(())
}
fn extract_data_bytes(obj: &Bound<'_, PyAny>) -> PyResult<Bytes> {
if let Ok(b) = obj.extract::<Vec<u8>>() {
return Ok(Bytes::from(b));
}
if let Ok(s) = obj.extract::<String>() {
return Ok(Bytes::from(s.into_bytes()));
}
Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(
"file data must be bytes or string",
))
}
fn guess_content_type(filename: &str) -> String {
let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"txt" => "text/plain".to_owned(),
"html" | "htm" => "text/html".to_owned(),
"json" => "application/json".to_owned(),
"xml" => "application/xml".to_owned(),
"pdf" => "application/pdf".to_owned(),
"png" => "image/png".to_owned(),
"jpg" | "jpeg" => "image/jpeg".to_owned(),
"gif" => "image/gif".to_owned(),
"svg" => "image/svg+xml".to_owned(),
"zip" => "application/zip".to_owned(),
_ => "application/octet-stream".to_owned(),
}
}