From f2b94e2062894140b101b8e43e83e5d86cc1f77f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:40:02 +0000 Subject: [PATCH 1/4] Initial plan From c585027d7a7eebd27efaf467b3029ea55a507ed6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:41:01 +0000 Subject: [PATCH 2/4] fix: prevent infinite loop in attrs_to_content_items for prefixed attributes like vm::pymethod --- crates/derive-impl/src/pyclass.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/derive-impl/src/pyclass.rs b/crates/derive-impl/src/pyclass.rs index d892db17ef6..c627bb5af6e 100644 --- a/crates/derive-impl/src/pyclass.rs +++ b/crates/derive-impl/src/pyclass.rs @@ -1986,16 +1986,16 @@ where while let Some((_, attr)) = iter.peek() { // take all cfgs but no py items let attr = *attr; - let attr_name = if let Some(ident) = attr.get_ident() { - ident.to_string() - } else { - continue; - }; - if attr_name == "cfg" { - cfgs.push(attr.clone()); - } else if ALL_ALLOWED_NAMES.contains(&attr_name.as_str()) { - break; + if let Some(ident) = attr.get_ident() { + let attr_name = ident.to_string(); + if attr_name == "cfg" { + cfgs.push(attr.clone()); + } else if ALL_ALLOWED_NAMES.contains(&attr_name.as_str()) { + break; + } } + // Always advance; multi-segment paths (e.g. vm::pymethod) return None + // from get_ident() and must be skipped rather than looping forever. iter.next(); } From c8b18a58a0fd18ac22d0578e8a99a41d42bbaef7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:51:21 +0000 Subject: [PATCH 3/4] fix: improve error message for path-qualified py attrs and add runtime test --- crates/derive-impl/src/pyclass.rs | 33 +++++++++++++++++++++++++++++-- crates/vm/src/lib.rs | 31 +++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/derive-impl/src/pyclass.rs b/crates/derive-impl/src/pyclass.rs index c627bb5af6e..7c1bf4d7631 100644 --- a/crates/derive-impl/src/pyclass.rs +++ b/crates/derive-impl/src/pyclass.rs @@ -1993,9 +1993,16 @@ where } else if ALL_ALLOWED_NAMES.contains(&attr_name.as_str()) { break; } + } else if attr + .path() + .segments + .last() + .is_some_and(|s| ALL_ALLOWED_NAMES.contains(&s.ident.to_string().as_str())) + { + // Multi-segment path whose last segment is a py* name (e.g. `vm::pymethod`). + // Stop here so the for-loop below can emit a diagnostic with the correct span. + break; } - // Always advance; multi-segment paths (e.g. vm::pymethod) return None - // from get_ident() and must be skipped rather than looping forever. iter.next(); } @@ -2004,6 +2011,28 @@ where let attr_name = if let Some(ident) = attr.get_ident() { ident.to_string() } else { + // Multi-segment path: if the last segment is a known py* name, the user + // likely wrote `#[vm::pymethod]` instead of `#[pymethod]`. Proc-macros + // cannot resolve namespace aliases, so the qualified form is never supported. + if let Some(last_seg) = attr.path().segments.last() { + let last_name = last_seg.ident.to_string(); + if ALL_ALLOWED_NAMES.contains(&last_name.as_str()) { + let full_path = attr + .path() + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect::>() + .join("::"); + bail_span!( + attr, + "found `#[{}]`, use `#[{}]` instead; \ + proc-macros cannot resolve namespace aliases", + full_path, + last_name + ); + } + } continue; }; if attr_name == "cfg" { diff --git a/crates/vm/src/lib.rs b/crates/vm/src/lib.rs index cca5b43457c..a91423c17a6 100644 --- a/crates/vm/src/lib.rs +++ b/crates/vm/src/lib.rs @@ -119,3 +119,34 @@ pub use rustpython_literal as literal; pub mod __exports { pub use paste; } + +#[cfg(test)] +mod tests { + // Regression test: verify that #[pyclass] and #[pymethod] expand correctly. + // Before the fix in attrs_to_content_items (crates/derive-impl/src/pyclass.rs), + // any inner attribute with a path prefix (e.g. #[vm::pymethod]) caused an + // infinite loop during macro expansion. + + #[pyclass(module = false, name = "TestItem")] + #[derive(Debug, PyPayload)] + struct TestItem { + value: i64, + } + + #[pyclass] + impl TestItem { + #[pymethod] + fn value(&self) -> i64 { + self.value + } + } + + #[test] + fn pyclass_and_pymethod_expand_correctly() { + // Verify the macro-generated code is functional at runtime. + // Before the fix, path-qualified inner attrs (e.g. #[vm::pymethod]) caused + // an infinite loop during macro expansion. + let item = TestItem { value: 42 }; + assert_eq!(item.value(), 42); + } +} From ce568883b0d7ee145cdb7acacf7690b6564aadeb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 07:14:10 +0000 Subject: [PATCH 4/4] test: add unit test in derive-impl verifying path-qualified py attrs return an error --- crates/derive-impl/src/pyclass.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/derive-impl/src/pyclass.rs b/crates/derive-impl/src/pyclass.rs index 7c1bf4d7631..cb097cb71fd 100644 --- a/crates/derive-impl/src/pyclass.rs +++ b/crates/derive-impl/src/pyclass.rs @@ -2073,3 +2073,27 @@ fn parse_vec_ident( })? .to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + // Regression test: path-qualified inner attrs like `#[vm::pymethod]` must return an error. + // Before the fix, `attrs_to_content_items` looped forever on such attributes. + #[test] + fn path_qualified_py_attr_returns_error() { + let attr: syn::Attribute = syn::parse_quote!(#[vm::pymethod]); + let result = + attrs_to_content_items(&[attr], |i, name| (i, name)); + let err = result.expect_err("expected error for path-qualified #[vm::pymethod]"); + let msg = err.to_string(); + assert!( + msg.contains("vm::pymethod"), + "error should mention the full path; got: {msg}" + ); + assert!( + msg.contains("pymethod"), + "error should mention the unqualified name; got: {msg}" + ); + } +}