Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
bf25f77
Address builtin members by field offset
youknowone Sep 25, 2026
6e359ec
Address member review findings
youknowone Sep 25, 2026
77354be
Declare members on payload fields
youknowone Sep 25, 2026
73ab3c6
Split nullable and non-null atomic cells
youknowone Sep 25, 2026
1643d90
Drop the unused cold trace_lines flag
youknowone Sep 25, 2026
6343798
Compute payload offsets from the merged Py header
youknowone Sep 25, 2026
a7107ae
Expose descriptor names and owner types as members
youknowone Sep 26, 2026
1e11d57
Expose remaining builtin tp_members as members
youknowone Sep 26, 2026
a7fed73
Spell a docless member as doc = false
youknowone Sep 26, 2026
45fca72
Make pymember readonly by default
youknowone Sep 26, 2026
2c6228c
Check the receiver of method descriptors
youknowone Sep 26, 2026
1a57108
Expose exception attributes as members
youknowone Sep 26, 2026
149922f
Avoid same-type casts in unsigned member conversion
youknowone Sep 26, 2026
e4897b1
Prefer an explicit pymember doc over the doc table
youknowone Sep 26, 2026
9413c06
Infer pymember kinds from field types
youknowone Sep 26, 2026
9d33366
Read nullable atomic cells only through load_owned
youknowone Sep 26, 2026
eaeb57a
Inherit static type slots from the whole MRO
youknowone Sep 26, 2026
947e203
Let Rust doc comments win over stored docstrings
youknowone Sep 27, 2026
0c40a56
Adapt members to upstream changes
youknowone Sep 27, 2026
cb242d6
Drop docstring text left as comments and inline downcast_cell
youknowone Sep 27, 2026
72e8f8d
Read sqlite row factories through load_owned
youknowone Sep 27, 2026
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
Prev Previous commit
Next Next commit
Let Rust doc comments win over stored docstrings
A /// comment on a Python-exposed item is its docstring; the
rustpython-doc entry is used only when there is none. #[pymember] reads
the field's /// comment instead of doc = "...". Doc comments that were
shadowed by a stored docstring become // comments, so no docstring
changes. AGENTS.md describes the rule.

Assisted-by: Grok:4.7
Assisted-by: Claude:claude-opus-5-5
  • Loading branch information
youknowone committed Sep 27, 2026
commit 947e20347173db40a99f713b027e3b2f9e8618c0
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ Run `./scripts/whats_left.py` to get a list of unimplemented methods, which is h

- Do not delete or rewrite existing comments unless they are factually wrong or directly contradict the new code.
- Do not add decorative section separators (e.g. `// -----------`, `// ===`, `/* *** */`). Use `///` doc-comments or short `//` comments only when they add value.
- Do not put `///` doc comments on items annotated with `#[pyattr]`, `#[pyclass]`, or `#[pyfunction]`. The derive macros pull authoritative docstrings from CPython via the `rustpython-doc` crate; a Rust doc comment overrides that source, and on `#[pyattr]` it is silently dropped.
- A `///` doc comment on a Python-exposed item (`#[pyattr]`, `#[pyclass]`, `#[pyfunction]`, `#[pymethod]`, `#[pyclassmethod]`, `#[pystaticmethod]`, `#[pygetset]`, `#[pymember]`, `#[pyslot]`, `#[pyexception]`, `#[pymodule]`, `#[pystruct_sequence]`) is that item's docstring and overrides the stored docs in `rustpython-doc` (`crates/doc`). Those stored docs are used only when the item has no `///` comment. Developer notes on these items must be `//` comments.

#### Avoid Duplicate Code in Branches

Expand Down
132 changes: 97 additions & 35 deletions crates/derive-impl/src/pyclass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,27 +426,26 @@ fn class_def_ty(self_ty: Option<&syn::Type>) -> Option<TokenStream> {
Some(quote!(#ty))
}

/// Const `Option<&'static str>`: table entry, else the Rust doc.
/// Generic impls cannot name `Self` from a nested const, so they keep the Rust doc.
/// Const `Option<&'static str>`. A non-empty Rust doc is the docstring.
/// The attribute table is used only when there is no Rust doc. Generic impls
/// cannot name `Self` from a nested const, so without a Rust doc they store none.
fn attr_doc_expr(self_ty: Option<&syn::Type>, attr: &str, rust_doc: Option<String>) -> TokenStream {
let fallback = match rust_doc {
Some(doc) => quote!(Some(#doc)),
None => quote!(None),
};
if let Some(doc) = rust_doc.filter(|doc| !doc.is_empty()) {
return quote!(Some(#doc));
}
let Some(ty) = class_def_ty(self_ty) else {
return fallback;
return quote!(None);
};
quote! {
{
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) = FOUND {
if doc.is_empty() { None } else { Some(doc) }
} else {
RUST_DOC
None
}
}
}
Expand Down Expand Up @@ -498,10 +497,11 @@ fn generate_class_def(
let attrs = extras.attrs;
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)
.or_else(|| attrs.doc());
let doc = attrs.doc().filter(|doc| !doc.is_empty()).or_else(|| {
rustpython_doc::get_qualified(module_key, name, None, true)
.filter(|doc| !doc.is_empty())
.map(str::to_owned)
});
let doc = if let Some(doc) = doc {
quote!(Some(#doc))
} else {
Expand Down Expand Up @@ -1878,27 +1878,25 @@ impl MemberItemMeta {
Ok(Some(name_value.value.to_token_stream()))
}

/// `Some(Some(text))` for `doc = "text"` (used as-is), `Some(None)` for
/// `doc = false`, and `None` when `doc` is absent.
fn doc(&self) -> Result<Option<Option<String>>> {
/// `doc = false` suppresses the stored docstring. The member docstring is
/// the field's `///` comment (or the `///` lines immediately above a
/// struct-level `#[pymember]`). A string `doc` is rejected.
fn suppress_doc(&self) -> Result<bool> {
let Some((_, meta)) = self.inner().meta_map.get("doc") else {
return Ok(None);
return Ok(false);
};
match meta {
Meta::NameValue(syn::MetaNameValue {
value: syn::Expr::Lit(syn::ExprLit { lit, .. }),
value:
syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Bool(lit),
..
}),
..
}) => match lit {
syn::Lit::Str(lit) => Ok(Some(Some(lit.value()))),
syn::Lit::Bool(lit) if !lit.value => Ok(Some(None)),
_ => Err(syn::Error::new(
lit.span(),
"#[pymember(doc = ...)] must be a string or `false`",
)),
},
}) if !lit.value => Ok(true),
_ => Err(syn::Error::new(
meta.span(),
"#[pymember(doc = ...)] must be a string or `false`",
"#[pymember] docstring is the `///` comment; only `doc = false` is accepted",
)),
}
}
Expand Down Expand Up @@ -1954,6 +1952,61 @@ struct BuiltMember {
span: Span,
}

fn is_doc_attr(attr: &Attribute) -> bool {
attr.path().is_ident("doc")
}

fn doc_attr_line(attr: &Attribute) -> Option<String> {
let syn::Meta::NameValue(name_value) = &attr.meta else {
return None;
};
let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(lit),
..
}) = &name_value.value
else {
return None;
};
Some(lit.value().trim().to_owned())
}

/// `#[pymember]` on a struct has no field. `///` lines placed immediately
/// above that attribute (after any other attribute) are its docstring and are
/// removed so they are not also the class docstring. Several struct-level
/// members each take the `///` lines directly above them.
fn split_struct_pymembers(attrs: &mut Vec<Attribute>) -> Vec<(Attribute, Option<String>)> {
let mut kept = Vec::with_capacity(attrs.len());
let mut members = Vec::new();
let mut pending_docs = Vec::new();
let mut pending_doc_attrs = Vec::new();
for attr in attrs.drain(..) {
if is_doc_attr(&attr)
&& let Some(text) = doc_attr_line(&attr)
{
pending_docs.push(text);
pending_doc_attrs.push(attr);
continue;
}
if attr.path().is_ident("pymember") {
let doc = if pending_docs.is_empty() {
None
} else {
Some(pending_docs.join("\n")).filter(|doc| !doc.is_empty())
};
pending_docs.clear();
pending_doc_attrs.clear();
members.push((attr, doc));
continue;
}
kept.append(&mut pending_doc_attrs);
pending_docs.clear();
kept.push(attr);
}
kept.append(&mut pending_doc_attrs);
*attrs = kept;
members
}

fn split_pymember_attrs(attrs: &mut Vec<Attribute>) -> Vec<Attribute> {
let mut kept = Vec::with_capacity(attrs.len());
let mut members = Vec::new();
Expand Down Expand Up @@ -2071,6 +2124,7 @@ fn build_member(
field: Option<(&syn::Field, usize)>,
cfgs: &[Attribute],
class_ty: &syn::Type,
rust_doc: Option<String>,
seen: &mut Vec<(String, Vec<Attribute>)>,
) -> Result<BuiltMember> {
let fallback_ident = field
Expand Down Expand Up @@ -2161,12 +2215,10 @@ fn build_member(
let kind_tokens = member_kind_tokens(kind.as_deref(), None, attr.span())?;
(offset_expr, quote!(), kind_tokens)
};
let doc = match meta.doc()? {
// An explicit string is the docstring. The attribute-doc table is not
// consulted. `doc = false` stores none. Omitted `doc` uses the table.
Some(Some(text)) => quote!(Some(#text)),
Some(None) => quote!(None),
None => attr_doc_expr(Some(class_ty), &name, None),
let doc = if meta.suppress_doc()? {
quote!(None)
} else {
attr_doc_expr(Some(class_ty), &name, rust_doc)
};
Ok(BuiltMember {
name,
Expand Down Expand Up @@ -2244,8 +2296,15 @@ fn extract_py_members(item: &mut Item) -> Result<(TokenStream, TokenStream, Toke
let class_ty: syn::Type = syn::parse_quote!(#struct_ident);
let mut seen = Vec::new();
let mut built = Vec::new();
for attr in split_pymember_attrs(&mut item_struct.attrs) {
built.push(build_member(&attr, None, &[], &class_ty, &mut seen)?);
for (attr, rust_doc) in split_struct_pymembers(&mut item_struct.attrs) {
built.push(build_member(
&attr,
None,
&[],
&class_ty,
rust_doc,
&mut seen,
)?);
}
let field_count = match &item_struct.fields {
syn::Fields::Named(fields) => fields.named.len(),
Expand All @@ -2259,13 +2318,16 @@ fn extract_py_members(item: &mut Item) -> Result<(TokenStream, TokenStream, Toke
syn::Fields::Unit => unreachable!(),
};
let cfgs = field_cfg_attrs(&field.attrs)?;
// Every #[pymember] on the field shares that field's `///` comment.
let rust_doc = field.attrs.doc().filter(|doc| !doc.is_empty());
let attrs = split_pymember_attrs(&mut field.attrs);
for attr in &attrs {
built.push(build_member(
attr,
Some((field, index)),
&cfgs,
&class_ty,
rust_doc.clone(),
&mut seen,
)?);
}
Expand Down
35 changes: 18 additions & 17 deletions crates/derive-impl/src/pymodule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,10 +255,11 @@ pub(crate) fn impl_pymodule(args: PyModuleArgs, module_item: Item) -> Result<Tok
let module_name = context.name.as_str();
let function_items = context.function_items.validate()?;
let attribute_items = context.attribute_items.validate()?;
let doc = rustpython_doc::get(module_name)
.filter(|doc| !doc.is_empty())
.map(str::to_owned)
.or(doc);
let doc = doc.filter(|doc| !doc.is_empty()).or_else(|| {
rustpython_doc::get(module_name)
.filter(|doc| !doc.is_empty())
.map(str::to_owned)
});
let doc = if let Some(doc) = doc {
quote!(Some(#doc))
} else {
Expand Down Expand Up @@ -708,22 +709,22 @@ impl ModuleItem for FunctionItem {
}

let module = args.module_name();
let rust_doc = args.attrs.doc();
// TODO: doc must exist at least one of code or CPython
let rust_doc = args.attrs.doc().filter(|doc| !doc.is_empty());
let docs = py_names
.iter()
.map(|py_name| {
let doc = rustpython_doc::get_qualified(module, py_name, None, false)
.filter(|doc| !doc.is_empty())
.map(str::to_owned)
.or_else(|| {
if args.context.is_sub {
unique_submodule_func_doc(py_name)
} else {
None
}
})
.or_else(|| rust_doc.clone());
let doc = rust_doc.clone().or_else(|| {
rustpython_doc::get_qualified(module, py_name, None, false)
.filter(|doc| !doc.is_empty())
.map(str::to_owned)
.or_else(|| {
if args.context.is_sub {
unique_submodule_func_doc(py_name)
} else {
None
}
})
});
let doc = match doc {
Some(doc) => quote!(Some(#doc)),
None => quote!(None),
Expand Down
11 changes: 8 additions & 3 deletions crates/derive-impl/src/pystructseq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::util::{ItemMeta, ItemMetaInner};
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{DeriveInput, Ident, Item, Result};
use syn_ext::ext::{AttributeExt, GetIdent};
use syn_ext::ext::{AttributeExt, AttributeIteratorExt, GetIdent};
use syn_ext::types::{Meta, PunctuatedNestedMeta};

// #[pystruct_sequence_data] - For Data structs
Expand Down Expand Up @@ -524,13 +524,18 @@ pub(crate) fn impl_pystruct_sequence(
} else {
class_name.clone()
};
let doc = rustpython_doc::get(module_class_name.as_str()).or_else(|| {
let db_doc = rustpython_doc::get(module_class_name.as_str()).or_else(|| {
// os re-exports the posix/nt struct sequences.
let class_name = module_class_name.strip_prefix("os.")?;
rustpython_doc::get(&format!("posix.{class_name}"))
.or_else(|| rustpython_doc::get(&format!("nt.{class_name}")))
});
let doc = doc.filter(|doc| !doc.is_empty());
let db_doc = db_doc.filter(|doc| !doc.is_empty()).map(str::to_owned);
let doc = struct_item
.attrs
.doc()
.filter(|doc| !doc.is_empty())
.or(db_doc);
let doc = match doc {
Some(doc) => quote!(Some(#doc)),
None => quote!(None),
Expand Down
5 changes: 3 additions & 2 deletions crates/derive-impl/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,8 +867,9 @@ fn args_const(pieces: &[SigPiece]) -> TokenStream {

/// Expression of type `Option<&'static str>`: the internal doc, or the plain
/// doc when the arguments cannot form a signature.
/// `doc` is a const `Option<&'static str>`. A table entry wins; an empty
/// string is no docstring. The text is composed into the internal doc so
/// `doc` is a const `Option<&'static str>` already chosen by the caller
/// (a Rust doc comment wins over the stored docstring). An empty string is
/// no docstring. The text is composed into the internal doc so
/// `__text_signature__` stays on the signature half.
pub(crate) fn internal_doc_tokens(
sig: &Signature,
Expand Down
27 changes: 21 additions & 6 deletions crates/derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ pub fn derive_from_args(input: TokenStream) -> TokenStream {
/// ...
/// }
/// ```
/// ## Docstrings
/// A `///` doc comment on the struct, or on a `#[pymethod]` / `#[pyclassmethod]` /
/// `#[pystaticmethod]` / `#[pygetset]` / `#[pymember]` item, is the Python
/// docstring and overrides the stored documentation. The stored documentation
/// is used only when that item has no `///` comment. Use `//` for developer notes.
/// ## Inner markers
/// ### pymethod/pyclassmethod/pystaticmethod
/// `pymethod` is used to mark a method of the Python class.
Expand Down Expand Up @@ -231,13 +236,16 @@ pub fn derive_from_args(input: TokenStream) -> TokenStream {
/// - `audit_read`: audit `object.__getattr__` before the load.
/// - `name`: Python attribute name. Defaults to the field name.
/// - `path`: subfield of the annotated field (`value` with `path = "re"`).
/// - `doc`: `doc = "text"` is that docstring and does not consult the stored
/// attribute documentation. `doc = false` stores no docstring. When `doc` is
/// omitted, the docstring is the stored attribute documentation.
/// - `doc`: only `doc = false`, which stores no docstring. Otherwise the
/// docstring is the field's `///` comment when present, and the stored
/// attribute documentation when the field has no `///` comment. Every
/// `#[pymember]` on one field shares that comment. A string `doc` is rejected.
///
/// A struct-level `#[pymember]` (after `#[pyclass]`) has no field. `offset` is
/// required there and rejected on a field. One field may carry several
/// `#[pymember]` attributes. A `#[cfg]` on the field gates that entry.
/// required there and rejected on a field. `///` lines placed immediately above
/// that attribute are its docstring; otherwise the stored attribute
/// documentation is used. One field may carry several `#[pymember]` attributes.
/// A `#[cfg]` on the field gates that entry.
/// ```rust, ignore
/// #[pymember(name = "fget")]
/// getter: PyAtomicRef<Option<PyObject>>,
Expand All @@ -255,6 +263,8 @@ pub fn pyclass(attr: TokenStream, item: TokenStream) -> TokenStream {

/// Helper macro to define `Exception` types.
/// More-or-less is an alias to `pyclass` macro.
/// A `///` comment on the exception type is its docstring and overrides the
/// stored documentation. Use `//` for developer notes.
///
/// This macro serves a goal of generating multiple
/// `BaseException` / `Exception`
Expand All @@ -272,6 +282,10 @@ pub fn pyexception(attr: TokenStream, item: TokenStream) -> TokenStream {
/// It defines a Python module in the form of a `module_def` function in the module;
/// this has to be used in a `add_native_module` to properly register the module.
/// Additionally, this macro defines 'MODULE_NAME' and 'DOC' in the module.
/// A `///` comment on the module is its docstring and overrides the stored
/// module documentation. `#[pyfunction]` works the same way: its `///` comment
/// is the function docstring and overrides the stored function documentation.
/// Use `//` for developer notes.
/// # Arguments
/// - `name`: the name of the python module,
/// by default, it is the name of the module, but this can be configured.
Expand Down Expand Up @@ -343,7 +357,8 @@ pub fn pymodule(attr: TokenStream, item: TokenStream) -> TokenStream {
/// Attribute macro for defining Python struct sequence types.
///
/// This macro is applied to an empty struct to create a Python type
/// that wraps a Data struct.
/// that wraps a Data struct. A `///` comment on that struct is the type's
/// docstring and overrides the stored documentation. Use `//` for developer notes.
///
/// # Example
/// ```ignore
Expand Down
2 changes: 1 addition & 1 deletion crates/stdlib/src/_asyncio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ pub(crate) mod _asyncio {
}
}

/// asyncio.Future implementation
// asyncio.Future implementation
#[pyattr]
#[pyclass(name = "Future", module = "_asyncio", traverse)]
#[derive(Debug, PyPayload)]
Expand Down
Loading