Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Resolve class attribute docs at macro time and drop rustpython-doc fr…
…om vm

- #[pyclass] and #[pystruct_sequence] emit a sorted PyClassDef::ATTR_DOCS
  table from the doc database; attribute and slot docs look up that table.
- rustpython-vm no longer depends on rustpython-doc.
- _operator.attrgetter, itemgetter, methodcaller and object get
  constructor Args with FromArgs::PARAMS, replacing the hardcoded class
  signature docs in class.rs.
- methodcaller() with no arguments raises "methodcaller needs at least one
  argument, the method name".

Assisted-by: Claude:claude-opus-5-5
Assisted-by: Grok:grok-4.7
  • Loading branch information
youknowone committed Sep 26, 2026
commit f9ff21bc3f795ad99a401ada4b2ff7e7bb219a48
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 61 additions & 0 deletions crates/derive-impl/src/class_docs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! Attribute docs resolved while expanding `#[pyclass]`.

use alloc::collections::BTreeSet;

use proc_macro2::TokenStream;
use quote::quote;

/// Modules `get_qualified` consults for `module`.
fn module_aliases(module: &str) -> Vec<String> {
let mut aliases = vec![module.to_owned()];
if module == "os" || module == "_os" {
aliases.push("posix".to_owned());
aliases.push("nt".to_owned());
}
if let Some(rest) = module.strip_prefix('_')
&& !rest.is_empty()
{
aliases.push(rest.to_owned());
}
if !module.is_empty() && !module.starts_with('_') {
aliases.push(format!("_{module}"));
}
if module != "builtins" && !module.starts_with('_') {
aliases.push("builtins".to_owned());
}
aliases
}

/// `get_attr`, then `class_attr_doc` when that entry is missing or empty.
/// An explicit empty entry with no fallback is `Some("")`.
fn resolved_attr_doc(module_name: Option<&str>, class: &str, attr: &str) -> Option<&'static str> {
let module_key = module_name.unwrap_or("builtins");
let exact = rustpython_doc::get_attr(module_key, class, attr);
let qualified = rustpython_doc::class_attr_doc(module_name, class, attr);
match exact {
Some(doc) if !doc.is_empty() => Some(doc),
Some(_) => qualified.or(Some("")),
None => qualified,
}
}

pub(crate) fn attr_docs_tokens(module_name: Option<&str>, class: &str) -> TokenStream {
let module_key = module_name.unwrap_or("builtins");
let mut names = BTreeSet::new();
for module in module_aliases(module_key) {
let prefix = format!("{module}.{class}.");
for (key, _) in rustpython_doc::DB {
if let Some(attr) = key.strip_prefix(&prefix)
&& !attr.is_empty()
&& !attr.contains('.')
{
names.insert(attr);
}
}
}
let entries = names.into_iter().filter_map(|attr| {
let doc = resolved_attr_doc(module_name, class, attr)?;
Some(quote! { (#attr, #doc) })
});
quote! { &[#(#entries),*] }
}
2 changes: 2 additions & 0 deletions crates/derive-impl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
#![doc(html_logo_url = "https://raw.githubusercontent.com/RustPython/RustPython/main/logo.png")]
#![doc(html_root_url = "https://docs.rs/rustpython-derive/")]

extern crate alloc;
extern crate proc_macro;

#[macro_use]
mod error;
#[macro_use]
mod util;

mod class_docs;
mod compile_bytecode;
mod from_args;
mod pyclass;
Expand Down
19 changes: 6 additions & 13 deletions crates/derive-impl/src/pyclass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,22 +445,13 @@ fn attr_doc_expr(self_ty: Option<&syn::Type>, attr: &str, rust_doc: Option<Strin
};
quote! {
{
const MODULE: &str = match <#ty as ::rustpython_vm::class::PyClassDef>::MODULE_NAME {
Some(module) => module,
None => "builtins",
};
const CLASS: &str = <#ty as ::rustpython_vm::class::PyClassDef>::NAME;
const EXACT: Option<&str> = ::rustpython_vm::__exports::rustpython_doc::get_attr(MODULE, CLASS, #attr);
const QUALIFIED: Option<&str> = ::rustpython_vm::__exports::rustpython_doc::class_attr_doc(
<#ty as ::rustpython_vm::class::PyClassDef>::MODULE_NAME,
CLASS,
const FOUND: Option<&str> = ::rustpython_vm::class::attr_doc(
<#ty as ::rustpython_vm::class::PyClassDef>::ATTR_DOCS,
#attr,
);
const RUST_DOC: Option<&str> = #fallback;
if let Some(doc) = EXACT {
if doc.is_empty() { QUALIFIED } else { Some(doc) }
} else if QUALIFIED.is_some() {
QUALIFIED
if let Some(doc) = FOUND {
if doc.is_empty() { None } else { Some(doc) }
} else {
RUST_DOC
}
Expand Down Expand Up @@ -501,6 +492,7 @@ fn generate_class_def(
attrs: &[Attribute],
) -> Result<TokenStream> {
let module_key = module_name.unwrap_or("builtins");
let attr_docs = crate::class_docs::attr_docs_tokens(module_name, name);
let doc = rustpython_doc::get_qualified(module_key, name, None, true)
.filter(|doc| !doc.is_empty())
.map(str::to_owned)
Expand Down Expand Up @@ -614,6 +606,7 @@ fn generate_class_def(
const MODULE_NAME: Option<&'static str> = #module_name;
const TP_NAME: &'static str = #module_class_name;
const DOC: Option<&'static str> = #doc;
const ATTR_DOCS: &'static [(&'static str, &'static str)] = #attr_docs;
const BASICSIZE: usize = #basicsize;
const UNHASHABLE: bool = #unhashable;

Expand Down
2 changes: 2 additions & 0 deletions crates/derive-impl/src/pystructseq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ pub(crate) fn impl_pystruct_sequence(
Some(doc) => quote!(Some(#doc)),
None => quote!(None),
};
let attr_docs = crate::class_docs::attr_docs_tokens(module_name.as_deref(), &class_name);

let output = quote! {
// The Python type struct - newtype wrapping PyTuple
Expand All @@ -548,6 +549,7 @@ pub(crate) fn impl_pystruct_sequence(
const MODULE_NAME: Option<&'static str> = #module_name_tokens;
const TP_NAME: &'static str = #module_class_name;
const DOC: Option<&'static str> = #doc;
const ATTR_DOCS: &'static [(&'static str, &'static str)] = #attr_docs;
const BASICSIZE: usize = 0;
const UNHASHABLE: bool = false;

Expand Down
1 change: 0 additions & 1 deletion crates/vm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ ruff_python_ast = { workspace = true, optional = true }
ruff_python_parser = { workspace = true }
ruff_text_size = { workspace = true, optional = true }
rustpython-compiler-core = { workspace = true }
rustpython-doc = { workspace = true }
rustpython-literal = { workspace = true }
rustpython-sre_engine = { workspace = true }
rustpython-unicode = { workspace = true }
Expand Down
22 changes: 18 additions & 4 deletions crates/vm/src/builtins/object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ use crate::{
AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine,
class::PyClassImpl,
convert::ToPyResult,
function::{Either, FuncArgs, PyArithmeticValue, PyComparisonValue, PySetterValue},
function::{
ArgumentError, Either, FromArgs, FuncArgs, Param, PyArithmeticValue, PyComparisonValue,
PySetterValue,
},
types::{Constructor, Initializer, PyComparisonOp},
};
use itertools::Itertools;
Expand All @@ -28,11 +31,22 @@ impl PyPayload for PyBaseObject {
}
}

pub struct ObjectArgs;

impl FromArgs for ObjectArgs {
const PARAMS: Option<&'static [Param]> = Some(&[]);

fn from_args(_vm: &VirtualMachine, args: &mut FuncArgs) -> Result<Self, ArgumentError> {
core::mem::take(args);
Ok(Self)
}
}

impl Constructor for PyBaseObject {
type Args = FuncArgs;
type Args = ObjectArgs;

// = object_new
fn slot_new(cls: PyTypeRef, args: Self::Args, vm: &VirtualMachine) -> PyResult {
fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult {
if !args.args.is_empty() || !args.kwargs.is_empty() {
// Check if type's __new__ != object.__new__
let tp_new = cls.get_attr(identifier!(vm, __new__));
Expand Down Expand Up @@ -111,7 +125,7 @@ pub(crate) fn generic_alloc(cls: PyTypeRef, _nitems: usize, vm: &VirtualMachine)
}

impl Initializer for PyBaseObject {
type Args = FuncArgs;
type Args = ObjectArgs;

// object_init: excess_args validation
fn slot_init(zelf: &PyObject, args: FuncArgs, vm: &VirtualMachine) -> PyResult<()> {
Expand Down
123 changes: 40 additions & 83 deletions crates/vm/src/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ pub trait PyClassDef {
const MODULE_NAME: Option<&'static str>;
const TP_NAME: &'static str;
const DOC: Option<&'static str> = None;
/// Attribute name → doc, sorted by name. `""` is an explicit empty doc.
const ATTR_DOCS: &'static [(&'static str, &'static str)] = &[];
const BASICSIZE: usize;
const ITEMSIZE: usize = 0;
const UNHASHABLE: bool = false;
Expand All @@ -144,97 +146,61 @@ pub trait PyClassDef {
type Base: PyClassDef;
}

const fn join_doc<const N: usize>(prefix: &str, body: &str) -> [u8; N] {
let mut out = [0u8; N];
let prefix = prefix.as_bytes();
let body = body.as_bytes();
const fn cmp_str(left: &str, right: &str) -> i8 {
let left = left.as_bytes();
let right = right.as_bytes();
let n = if left.len() < right.len() {
left.len()
} else {
right.len()
};
let mut i = 0;
while i < prefix.len() {
out[i] = prefix[i];
while i < n {
if left[i] != right[i] {
return if left[i] < right[i] { -1 } else { 1 };
}
i += 1;
}
let mut j = 0;
while j < body.len() {
out[i + j] = body[j];
j += 1;
if left.len() == right.len() {
0
} else if left.len() < right.len() {
-1
} else {
1
}
out
}

macro_rules! prefixed_class_doc {
($name:ident, $prefix:literal, $key:literal) => {
const $name: &str = {
const BODY: &str = match rustpython_doc::get($key) {
Some(doc) => doc,
None => "",
};
const PREFIX: &str = $prefix;
const N: usize = PREFIX.len() + BODY.len();
const B: [u8; N] = join_doc::<N>(PREFIX, BODY);
match core::str::from_utf8(&B) {
Ok(doc) => doc,
Err(_) => PREFIX,
}
};
};
}

prefixed_class_doc!(
ATTRGETTER_SLOT_DOC,
"attrgetter(attr, /, *attrs)\n--\n\n",
"_operator.attrgetter"
);
prefixed_class_doc!(
ITEMGETTER_SLOT_DOC,
"itemgetter(item, /, *items)\n--\n\n",
"_operator.itemgetter"
);
prefixed_class_doc!(
METHODCALLER_SLOT_DOC,
"methodcaller(name, /, *args, **kwargs)\n--\n\n",
"_operator.methodcaller"
);

const OBJECT_SLOT_DOC: &str = {
const BODY: &str = match rustpython_doc::get("builtins.object") {
Some(doc) => doc,
None => "",
};
const ARGS: &[crate::function::SigArg] = &[crate::function::SigArg {
name: "",
params: Some(&[]),
}];
const N: usize = crate::function::internal_doc_len("object", ARGS, BODY);
const B: [u8; N] = crate::function::internal_doc_bytes::<N>("object", ARGS, BODY);
match core::str::from_utf8(&B) {
Ok(doc) => doc,
Err(_) => BODY,
/// Doc for `name` in a sorted attribute-doc table.
#[must_use]
pub const fn attr_doc<'a>(table: &'a [(&'a str, &'a str)], name: &str) -> Option<&'a str> {
let mut lo = 0;
let mut hi = table.len();
while lo < hi {
let mid = (lo + hi) / 2;
let ord = cmp_str(table[mid].0, name);
if ord == 0 {
return Some(table[mid].1);
} else if ord < 0 {
lo = mid + 1;
} else {
hi = mid;
}
}
};
None
}

pub trait PyClassImpl: PyClassDef {
const TP_FLAGS: PyTypeFlags = PyTypeFlags::DEFAULT;

/// Plain docstring for each [`SLOT_DEFS`] entry, resolved while this impl
/// is compiled. `None` keeps the slotdef text.
const SLOT_DOCS: [Option<&'static str>; SLOT_DEFS_COUNT] = {
let module = match Self::MODULE_NAME {
Some(module) => module,
None => "builtins",
};
let mut docs = [None; SLOT_DEFS_COUNT];
let mut i = 0;
while i < SLOT_DEFS_COUNT {
let name = SLOT_DEFS[i].name;
let exact = rustpython_doc::get_attr(module, Self::NAME, name);
docs[i] = if let Some(doc) = exact {
if doc.is_empty() {
rustpython_doc::class_attr_doc(Self::MODULE_NAME, Self::NAME, name)
} else {
Some(doc)
}
} else {
rustpython_doc::class_attr_doc(Self::MODULE_NAME, Self::NAME, name)
docs[i] = match attr_doc(Self::ATTR_DOCS, SLOT_DEFS[i].name) {
Some(doc) if !doc.is_empty() => Some(doc),
_ => None,
};
i += 1;
}
Expand Down Expand Up @@ -357,15 +323,6 @@ pub trait PyClassImpl: PyClassDef {
itemsize: Self::ITEMSIZE,
doc: if let Some(doc) = Self::INTERNAL_DOC {
Some(doc)
} else if Self::MODULE_NAME.is_none() && Self::NAME.as_bytes() == b"object" {
Some(OBJECT_SLOT_DOC)
} else if Self::MODULE_NAME == Some("_operator") {
match Self::NAME {
"attrgetter" => Some(ATTRGETTER_SLOT_DOC),
"itemgetter" => Some(ITEMGETTER_SLOT_DOC),
"methodcaller" => Some(METHODCALLER_SLOT_DOC),
_ => Self::DOC,
}
} else {
Self::DOC
},
Expand Down
1 change: 0 additions & 1 deletion crates/vm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,4 @@ pub use rustpython_literal as literal;
#[doc(hidden)]
pub mod __exports {
pub use paste;
pub use rustpython_doc;
}
Loading