blob: e5135600d30017ecffbbddfc02ddd138b50ac281 [file]
// 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 anyhow::{Context, Result, anyhow, bail};
use std::fs;
use std::path::Path;
use syn::{
Expr, ExprLit, Field, GenericArgument, Item, ItemStruct, Lit, Meta, PathArguments, Type,
TypePath,
};
/// The Python type an operator option field maps to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionType {
/// `Option<String>`
Str,
/// `Option<usize>`
Int,
/// `Option<bool>`
Bool,
/// `Option<jiff::Timestamp>`
DateTime,
/// `Option<HashMap<String, String>>`
Dict,
}
/// One keyword option of an operator method.
#[derive(Debug, Clone)]
pub struct OptionField {
/// The option name, e.g. `if_match`.
pub name: String,
/// The value type of this option.
pub ty: OptionType,
/// The doc comment of the field, trimmed.
pub doc: String,
}
/// One option type, parsed from a `#[pyclass]` struct in
/// `bindings/python/src/options.rs`, e.g. `ReadOptions`.
#[derive(Debug, Clone)]
pub struct Options {
/// The struct name, e.g. `ReadOptions`.
pub name: String,
/// The doc comment of the struct, trimmed.
pub doc: String,
/// The fields of the struct.
pub fields: Vec<OptionField>,
}
/// Parse all `#[pyclass]` option structs from `bindings/python/src/options.rs`.
///
/// A struct is treated as an option type when it is annotated with
/// `#[pyclass]` and its name ends with `Options`.
pub fn parse(path: &Path) -> Result<Vec<Options>> {
let content =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let ast = syn::parse_file(&content)?;
let mut options = Vec::new();
for item in ast.items {
if let Item::Struct(struct_) = item
&& is_option_struct(&struct_)
{
options.push(parse_struct(&struct_)?);
}
}
Ok(options)
}
/// Whether a struct maps to a generated `*OptionsKwargs` TypedDict.
fn is_option_struct(struct_: &ItemStruct) -> bool {
struct_.ident.to_string().ends_with("Options")
&& struct_
.attrs
.iter()
.any(|attr| attr.path().is_ident("pyclass"))
}
fn parse_struct(struct_: &ItemStruct) -> Result<Options> {
let name = struct_.ident.to_string();
let doc = doc_lines(&struct_.attrs).join("\n").trim().to_string();
let fields = match &struct_.fields {
syn::Fields::Named(named) => &named.named,
_ => bail!("option struct {name} must have named fields"),
};
let mut parsed = Vec::with_capacity(fields.len());
for field in fields {
parsed.push(parse_field(field)?);
}
Ok(Options {
name,
doc,
fields: parsed,
})
}
fn parse_field(field: &Field) -> Result<OptionField> {
let name = field
.ident
.as_ref()
.ok_or_else(|| anyhow!("option field is unnamed: {field:?}"))?
.to_string();
let ty = parse_type(&field.ty)?;
let doc = doc_lines(&field.attrs).join("\n").trim().to_string();
Ok(OptionField { name, ty, doc })
}
/// Parse the type of an option field, which must be `Option<T>`.
fn parse_type(ty: &Type) -> Result<OptionType> {
let Type::Path(TypePath { path, .. }) = ty else {
bail!("unsupported option field type: {ty:?}");
};
let segment = path
.segments
.last()
.ok_or_else(|| anyhow!("option type path is empty: {ty:?}"))?;
if segment.ident != "Option" {
bail!("option field must be Option<T>, found {segment:?}");
}
let PathArguments::AngleBracketed(args) = &segment.arguments else {
bail!("Option must have angle bracketed arguments");
};
let Some(GenericArgument::Type(Type::Path(inner))) = args.args.first() else {
bail!("Option must contain a type argument");
};
let inner_segment = inner
.path
.segments
.last()
.ok_or_else(|| anyhow!("option inner type path is empty: {inner:?}"))?;
Ok(match inner_segment.ident.to_string().as_str() {
"String" => OptionType::Str,
"usize" => OptionType::Int,
"bool" => OptionType::Bool,
// jiff::Timestamp
"Timestamp" => OptionType::DateTime,
// HashMap<String, String>
"HashMap" => OptionType::Dict,
other => bail!("unsupported option type: {other}"),
})
}
/// Collect the doc comment of a struct or field as trimmed lines.
fn doc_lines(attrs: &[syn::Attribute]) -> Vec<String> {
attrs
.iter()
.filter(|attr| attr.path().is_ident("doc"))
.filter_map(|attr| {
if let Meta::NameValue(meta) = &attr.meta
&& let Expr::Lit(ExprLit {
lit: Lit::Str(s), ..
}) = &meta.value
{
return Some(s.value().trim().to_string());
}
None
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_options() {
let path = crate::workspace_dir().join("bindings/python/src/options.rs");
let options = parse(&path).unwrap();
let names: Vec<&str> = options.iter().map(|o| o.name.as_str()).collect();
assert_eq!(
names,
[
"ReadOptions",
"WriteOptions",
"ListOptions",
"StatOptions",
"DeleteOptions"
]
);
let read = &options[0];
assert_eq!(read.doc, "Options for `read` operations.");
assert_eq!(read.fields.len(), 14);
assert_eq!(read.fields[0].name, "version");
assert_eq!(read.fields[0].ty, OptionType::Str);
assert_eq!(read.fields[4].name, "offset");
assert_eq!(read.fields[4].ty, OptionType::Int);
assert_eq!(read.fields[9].name, "if_modified_since");
assert_eq!(read.fields[9].ty, OptionType::DateTime);
assert_eq!(read.fields[0].doc, "The version of the file.");
let write = &options[1];
assert_eq!(write.fields.len(), 11);
assert_eq!(write.fields[10].name, "user_metadata");
assert_eq!(write.fields[10].ty, OptionType::Dict);
assert_eq!(
write.fields[10].doc,
"The user metadata to set on the file."
);
let delete = &options[4];
assert_eq!(delete.fields.len(), 3);
assert_eq!(
delete.fields[1].doc,
"If True, delete the path recursively.\n\nOnly supported on backends that support recursive delete."
);
assert_eq!(delete.fields[2].name, "if_match");
assert_eq!(delete.fields[2].ty, OptionType::Str);
assert_eq!(
delete.fields[2].doc,
"The ETag that the object must match before deletion."
);
}
}