From e8a845ccfaa95774eceea689d05cd16db6502791 Mon Sep 17 00:00:00 2001 From: monchin Date: Sun, 14 Jun 2026 12:38:20 +0800 Subject: [PATCH 1/3] feat: support PyO3 initializer mode (separate __new__/__init__) --- CHANGELOG.md | 4 + Cargo.lock | 7 + Cargo.toml | 3 +- README.md | 4 +- examples/initializer_mode_sample/Cargo.toml | 11 + .../initializer_mode_sample.pyi | 15 + examples/initializer_mode_sample/rylai.toml | 5 + examples/initializer_mode_sample/src/lib.rs | 38 +++ justfile | 1 + .../distinguish-new-and-init/design.md | 91 ++++++ .../distinguish-new-and-init/proposal.md | 36 +++ .../specs/constructor-emission/spec.md | 81 +++++ .../changes/distinguish-new-and-init/tasks.md | 43 +++ src/generator.rs | 276 +++++++++++++++++- 14 files changed, 607 insertions(+), 8 deletions(-) create mode 100644 examples/initializer_mode_sample/Cargo.toml create mode 100644 examples/initializer_mode_sample/python/initializer_mode_sample/initializer_mode_sample.pyi create mode 100644 examples/initializer_mode_sample/rylai.toml create mode 100644 examples/initializer_mode_sample/src/lib.rs create mode 100644 openspec/changes/distinguish-new-and-init/design.md create mode 100644 openspec/changes/distinguish-new-and-init/proposal.md create mode 100644 openspec/changes/distinguish-new-and-init/specs/constructor-emission/spec.md create mode 100644 openspec/changes/distinguish-new-and-init/tasks.md diff --git a/CHANGELOG.md b/CHANGELOG.md index cf2d6de..7fa22c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- PyO3 [Initializer](https://pyo3.rs/main/class.html#initializer) mode support: when a class defines both `#[new]` and an explicit `fn __init__`, `#[new]` now emits `__new__(cls, ...) -> Self` and `__init__` emits `__init__(self, ...) -> None` — previously both collapsed to a single (duplicate) `__init__` definition. The `__new__` return type reuses the version-aware `Self` renderer (`t.Self` for ≥ 3.11, class name for older). `#[new]` without an explicit `__init__` is unchanged. Also aligns `[[override]]` `#[new]` aliasing with the generated stub name. + ### Fixed - Exclude `Self`-dependent PyO3 receiver types (`Py`, `Bound<'_, Self>`, `&Bound<'_, Self>`, `&Borrowed<'_, Self>`, `PyRef<'_, Self>`, `PyRefMut<'_, Self>`) from generated stub parameters **only at the receiver position** (first parameter of an instance method, getter, or setter). The same type used as a later parameter (e.g. `fn m(&self, other: Py)`) or in a `#[staticmethod]` / `#[classmethod]` is now kept as a regular parameter and mapped to the class type. Fixes incorrect stubs where such parameters were either leaked as extra `self`-like bindings (`slf`, `slf_handle`, …) or silently dropped from the signature — both triggered mypy/pyright errors at correct call sites. (#5, #6) diff --git a/Cargo.lock b/Cargo.lock index 08e89fe..319ab0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,6 +225,13 @@ dependencies = [ "rustversion", ] +[[package]] +name = "initializer_mode_sample" +version = "0.1.0" +dependencies = [ + "pyo3", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" diff --git a/Cargo.toml b/Cargo.toml index 4855396..08f96e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,8 @@ members = [ "examples/basic_function_sample", "examples/cross_module_sample", "examples/override_sample", - "examples/macro_expand_sample" + "examples/macro_expand_sample", + "examples/initializer_mode_sample" ] resolver = "2" diff --git a/README.md b/README.md index b680b7e..f672a85 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ Together, this makes Rylai easy to integrate into CI, docs, or local dev without **Advanced:** - Python-version-aware output (`T | None` for ≥ 3.10, `t.Optional[T]` for older; PEP 585 built-in generics for ≥ 3.9; `t.Self` for ≥ 3.11) +- PyO3 constructor semantics per the [Initializer docs](https://pyo3.rs/main/class.html#initializer): `#[new]` maps to `__init__` by default; when a class also defines an explicit `fn __init__`, `#[new]` maps to `__new__(cls, ...) -> Self` and `__init__` stays `__init__` — never producing duplicate `__init__` definitions - `create_exception!` / `pyo3::create_exception!` support — emits matching `class Name(Base): ...` stubs - Generates `__all__` in every stub; configurable globally and per file - Multi-module stubs via `#[pyclass(module = "...")]` with automatic cross-module imports @@ -157,6 +158,7 @@ The `examples/` directory contains several self-contained sample projects, each | `override_sample` | `[[override]]` via `stub` and via `param_types` / `return_type`, `[[add_content]]` | `cargo run -- examples/override_sample --output examples/override_sample/python/override_sample` | | `add_content_sample` | `[[add_content]]` with `tail` location and `location = "file"` to create standalone `.pyi` files | `cargo run -- examples/add_content_sample --output examples/add_content_sample/python/add_content_sample` | | `macro_expand_sample` | `[[macro_expand]]` auto-discover and explicit modes | `cargo run -- examples/macro_expand_sample --output examples/macro_expand_sample/python/macro_expand_sample` | +| `initializer_mode_sample` | PyO3 [Initializer](https://pyo3.rs/main/class.html#initializer) mode: `#[new]` + explicit `fn __init__` → separate `__new__` / `__init__` | `cargo run -- examples/initializer_mode_sample --output examples/initializer_mode_sample/python/initializer_mode_sample` | #### Regenerating all example stubs @@ -312,7 +314,7 @@ return_type = "dict[str, t.Any]" - Module-level: `{module}::{function}` - Class method: `{module}::{class}::{method}` -`{module}` is the **logical Python module of the stub file** — the top-level `#[pymodule]` name for the root `.pyi`, or the full `#[pyclass(module = "...")]` string for submodule stubs (e.g. `pkg.abc::MyClass::method`). `class` may be the Rust struct name or `#[pyclass(name = "...")]`. `method` is the Rust `fn` ident or `#[pyo3(name = "...")]`. For `#[new]`, use `...::__init__` as the method segment. +`{module}` is the **logical Python module of the stub file** — the top-level `#[pymodule]` name for the root `.pyi`, or the full `#[pyclass(module = "...")]` string for submodule stubs (e.g. `pkg.abc::MyClass::method`). `class` may be the Rust struct name or `#[pyclass(name = "...")]`. `method` is the Rust `fn` ident or `#[pyo3(name = "...")]`. For `#[new]`, use the method segment matching its generated stub name: `...::__init__` normally, or `...::__new__` in PyO3 [Initializer](https://pyo3.rs/main/class.html#initializer) mode (class defines both `#[new]` and an explicit `fn __init__`). The Rust `fn` ident (e.g. `...::py_new`) always works as a fallback. See `examples/override_sample/` for a working example. diff --git a/examples/initializer_mode_sample/Cargo.toml b/examples/initializer_mode_sample/Cargo.toml new file mode 100644 index 0000000..08217d5 --- /dev/null +++ b/examples/initializer_mode_sample/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "initializer_mode_sample" +version = "0.1.0" +edition = "2024" + +[lib] +name = "initializer_mode_sample" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = "0.27.0" diff --git a/examples/initializer_mode_sample/python/initializer_mode_sample/initializer_mode_sample.pyi b/examples/initializer_mode_sample/python/initializer_mode_sample/initializer_mode_sample.pyi new file mode 100644 index 0000000..8781506 --- /dev/null +++ b/examples/initializer_mode_sample/python/initializer_mode_sample/initializer_mode_sample.pyi @@ -0,0 +1,15 @@ +# Auto-generated by rylai. Do not edit manually. + +from __future__ import annotations + +import typing as t + +__all__ = [ + "WithInit", +] + +@t.final +class WithInit: + """A class that carries both `#[new]` and an explicit `fn __init__` (PyO3 initializer mode).""" + def __new__(cls, value: int) -> WithInit: ... + def __init__(self, value: int) -> None: ... diff --git a/examples/initializer_mode_sample/rylai.toml b/examples/initializer_mode_sample/rylai.toml new file mode 100644 index 0000000..59d3d63 --- /dev/null +++ b/examples/initializer_mode_sample/rylai.toml @@ -0,0 +1,5 @@ +format = [ + "uvx ruff format", + "uvx ruff check --select I --fix", + "uvx ruff check --select F401 --fix", +] diff --git a/examples/initializer_mode_sample/src/lib.rs b/examples/initializer_mode_sample/src/lib.rs new file mode 100644 index 0000000..6673c32 --- /dev/null +++ b/examples/initializer_mode_sample/src/lib.rs @@ -0,0 +1,38 @@ +//! PyO3 initializer-mode example. +//! +//! See : when a class declares both +//! `#[new]` and a method literally named `__init__`, PyO3 registers `#[new]` as +//! Python `__new__` and `fn __init__` as `__init__`. Rylai accordingly emits +//! `__new__(cls, ...) -> Self` and `__init__(self, ...) -> None` separately, +//! rather than collapsing both into `__init__`. +//! +//! Real-world initializer scenarios typically extend a native type (e.g. +//! `#[pyclass(extends = PyDict)]`) to take over the initialization flow; this +//! sample omits the base class to keep the build stable and only demonstrates +//! the coexistence of `#[new]` and `__init__`, the trigger condition for the +//! Rylai fork. + +use pyo3::prelude::*; + +#[pymodule] +mod initializer_mode_sample { + use pyo3::prelude::*; + + /// A class that carries both `#[new]` and an explicit `fn __init__` (PyO3 initializer mode). + #[pyclass] + pub struct WithInit; + + #[pymethods] + impl WithInit { + #[new] + fn new(value: i32) -> Self { + let _ = value; + Self + } + + fn __init__(&self, value: i32) -> PyResult<()> { + let _ = value; + Ok(()) + } + } +} diff --git a/justfile b/justfile index 28fdde2..7e07fa5 100644 --- a/justfile +++ b/justfile @@ -7,5 +7,6 @@ gen-pyi-examples: @just gen-pyi add_content_sample @just gen-pyi basic_function_sample @just gen-pyi cross_module_sample + @just gen-pyi initializer_mode_sample @just gen-pyi macro_expand_sample @just gen-pyi override_sample diff --git a/openspec/changes/distinguish-new-and-init/design.md b/openspec/changes/distinguish-new-and-init/design.md new file mode 100644 index 0000000..e98ac0b --- /dev/null +++ b/openspec/changes/distinguish-new-and-init/design.md @@ -0,0 +1,91 @@ +## Context + +Rylai 把 PyO3 `#[new]` 一律映射为 Python `__init__`(见 [generator.rs:705-707](../../../src/generator.rs#L705))。`detect_method_kind` 只看 `#[new]` 属性,不看 Rust 方法名,因此 `#[new] fn new()`、`#[new] fn py_new()`、`#[new] fn __new__()` 都生成 `def __init__`。 + +PyO3 另有 [Initializer 模式](https://pyo3.rs/main/class.html#initializer):当类(通常 `#[pyclass(extends = PyDict)]` 继承原生类型)需要控制初始化流程时,会**同时**定义 `#[new]`(对应 Python `__new__`)与字面量名为 `__init__` 的方法(对应 Python `__init__`)。当前 Rylai 把两者都生成成 `def __init__`,既偏离 PyO3 语义,又产生重复定义。 + +现有基础设施可复用:`resolve_type` → `type_map::map_type(rust_type, policy, current_self_type, known_classes)` 已对任何返回 `Self` 的方法做版本感知渲染(py ≥ 3.11 → `t.Self`,py < 3.11 → 类名),由 `GenCtx::current_self_type`(在 `gen_method` 前设为类名)与 `RenderPolicy`(携带 `python_version`)共同决定。staticmethod 返回 `PyResult` 已走这条路。 + +## Goals / Non-Goals + +**Goals:** +- 让 Rylai 的构造器输出与 PyO3 官方 Initializer 语义一致。 +- 复用现有 `Self` 渲染机制,零新增版本逻辑。 +- 修复 `#[new]` + 显式 `__init__` 的重复 `def __init__` bug。 + +**Non-Goals:** +- 不支持 `#[pyo3(name = "__init__")]`(PyO3 不按它识别魔法方法)。 +- 不做语义校验 / 报警(无 `#[new]` 但有 `__init__` 等组合不报)。 +- 不改变"无显式 `__init__` 时 `#[new]` → `__init__`"的既有行为。 + +## Decisions + +### 决策 1:分叉依据是"是否存在显式 `__init__`",而非 `#[new]` 的方法名 + +`#[new]` 在 Initializer 模式下生成 `__new__`,**仅当**类中存在字面量名为 `__init__` 的方法。即便 `#[new]` 的 Rust 名就叫 `__new__`,只要没有显式 `__init__`,仍输出 `__init__`。 + +**为什么不是"按 `#[new]` 方法名分叉"**:PyO3 文档明确"the Rust method name is not important here"——`#[new]` 在运行时永远注册为 Python `__new__`,与 Rust 名无关。若按 Rust 名分叉(如 `fn __new__` → 输出 `__new__`),则 `#[new] fn new()` 该输出什么会产生混乱,且偏离 PyO3 语义。Rylai 当前把 `#[new]` → `__init__` 本就是一个为贴合 Python 习惯的**语义转换**;分叉的唯一正当触发条件是"`__init__` 这个槽位已被显式占用"。 + +``` +判定:has_explicit_init = class.methods.any(|m| m.rust_ident == "__init__" && m.kind != MethodKind::New) + +┌──────────────┬──────────────────┬──────────────────────────┐ +│ __init__存在 │ #[new] 生成 │ 显式 __init__ 生成 │ +├──────────────┼──────────────────┼──────────────────────────┤ +│ 否 (99%) │ def __init__ │ — │ +│ │ -> None, self │ │ +├──────────────┼──────────────────┼──────────────────────────┤ +│ 是 (Init模式)│ def __new__ │ def __init__ │ +│ │ -> Self, cls │ -> None, self │ +└──────────────┴──────────────────┴──────────────────────────┘ +``` + +### 决策 2:检测用字面量 `rust_ident == "__init__"` 且排除 `#[new]` 本身,跨全部 `#[pymethods]` 块 + +PyO3 按字面量 Rust 函数名识别 `__init__`,不识别 `#[pyo3(name = "__init__")]`。Rylai 与之对齐:只匹配 `rust_ident == "__init__"`。 + +**必须排除 `#[new]` 本身**:`#[new] fn __init__()` 是合法的——PyO3 把它当构造器(运行时注册为 Python `__new__`),其 `kind == MethodKind::New`、`rust_ident == "__init__"`。若不排除,它会被误判成"显式 `__init__`",使一个普通 constructor 错误进入 Initializer 模式(生成 `__new__` 而非 `__init__`)。语义原则:**一个方法不能同时是 constructor(`#[new]`)和 initializer(显式 `__init__`)**——它们是同一类的两个不同槽位。故检测条件 MUST 为 `rust_ident == "__init__" && kind != MethodKind::New`。等价的强命题:只要类内名为 `__init__` 的方法**有且只有一个**,无论它带不带 `#[new]`,都不可能是 Initializer 模式(Initializer 要求 `#[new]` 与 `__init__` 是两个不同方法)。 + +**范围**:扫描 `PyClass.methods` 的全部方法(collector 已跨所有 `#[pymethods]` 块聚合,含 `multiple-pymethods` feature)。无需遍历原始 AST。 + +### 决策 3:`__new__` 返回类型复用现有 `Self` 渲染,首参 `cls` + +Initializer 模式下 `#[new]` 的返回类型标记为 `Self`,丢给现有 `resolve_type`,自动产出 `t.Self`(py ≥ 3.11)或类名(py < 3.11)——与 staticmethod 返回 `Self` 的现有行为完全一致。 + +**为什么不是固定类名**:会失去继承场景准确性,且与现有"返回 Self 的方法"行为不一致,引入两套渲染路径。 + +`__new__` 首参按 Python 约定用 `cls`(非 `self`),且**不注解类型**(裸 `cls`)。这遵循 [PEP 673](https://peps.python.org/pep-0673/) / typeshed 惯例——`__new__(cls, ...) -> Self` 正是 PEP 673 的标准示例写法,`cls`(与 `self` 一样)由类型检查器自动推断,显式 `cls: type[Self]` 冗余且偏离惯例(旧 TypeVar 写法 `cls: type[_S] -> _S` 已被 Ruff PYI019 标记为应替换为 `Self`)。当前 `method_params(m, true, ...)` 的 `with_self=true` 注入裸 `self`,`__new__` 分支同理注入裸 `cls`——同一机制换名,无需构造类型注解。 + +### 决策 4:数据流——`has_explicit_init` 在 generator 层即时计算,不污染 model + +在 `gen_class`(类生成入口)开始时,扫描 `class.methods` 计算 `has_explicit_init: bool`,作为局部上下文传入每个 `gen_method`。遇到 `MethodKind::New` 时据此分叉。 + +**为什么不在 model 上加字段**:这是"生成期策略信号",与收集期确定的 `MethodKind` 性质不同;model 只需如实记录方法,分叉策略属于生成逻辑。即时计算也避免了 collector↔generator 间额外的耦合。 + +**Alternative(已否决)**:在 `PyClass` 上加 `has_explicit_init` 字段——可行但把策略信息固化进数据模型,且 collector 当前无需感知此区分。 + +### 决策 5:override 别名单一、恒等于生成名(硬约束) + +现 `#[new]` 的 override 匹配额外允许 `__init__` 别名([generator.rs:389-396](../../../src/generator.rs#L389))。这个别名不是凭空起的——它精确等于 `#[new]` 当前生成的 stub 名(`__init__`)。这已是当前代码隐式遵循的不变量:**override 的 dunder 别名恒等于该方法实际生成的 stub 名**。 + +Initializer 模式下 `#[new]` 的生成名变为 `__new__`,故其 override 别名 MUST 同步变为 `__new__`。这不是"可微调的偏好",而是**硬约束**:若同时保留 `__init__` 和 `__new__` 两个别名指向 `#[new]`,则一个 override 项 `m::Class::__init__` 会同时命中 `#[new]`(双别名之一)与显式 `__init__`(rust_ident/name 直匹配),导致**单个 override 项重复替换两个方法的 stub**——这是 bug。 + +**结论**: +- override 匹配始终支持 rust_ident 直匹配(兜底); +- `#[new]` 的 dunder 别名按生成模式动态取**唯一**值——非 Initializer 模式为 `__init__`,Initializer 模式为 `__new__`,MUST NOT 同时持有两个; +- 显式 `__init__` 仅靠 rust_ident/name 直匹配命中,无需额外别名; +- 由于 `__new__` ≠ `__init__`(生成名互异),Initializer 模式下 override 天然精确命中、无重复。 + +## Risks / Trade-offs + +- **[Initializer 模式罕见 → 缺测试覆盖]** → 新增 `examples/initializer_mode_sample`(继承 `PyDict`,同时定义 `#[new]` + `__init__`),并在 `tests/` 加对应集成测试,锁定 `__new__`/`__init__` 分叉与首参/返回类型。 +- **[`__new__` 首参 `cls` 与现有 `with_self` 注入逻辑冲突]** → 在 `MethodKind::New` 的 Initializer 分支单独处理首参注入(`cls`),不改动其他方法路径,隔离影响。 +- **[override 行为变化影响现有配置]** → 现状无人在 Rylai 内使用 Initializer 模式(否则早就触发重复 `__init__` bug),实际影响面近零;仍保留 rust_ident 直匹配作兜底。 +- **[trade-off:仅识别字面量 `__init__`]** → 与 PyO3 完全一致,但若未来 PyO3 改变魔法方法识别方式需同步跟进;可接受。 + +## Migration Plan + +- stub 生成器输出型变更,对无 Initializer 模式的现有用户零影响(输出不变)。 +- 对 Initializer 模式用户:从"错误(重复 `__init__`)"变为"正确(`__new__` + `__init__`)"。 +- 无需特殊迁移;按 conventional commits 发一个 minor bump,CHANGELOG 注明遵循 PyO3 Initializer 文档。 +- README 增补一段"Rylai 遵循 PyO3 构造器语义"的说明,引用 [PyO3 Initializer 文档](https://pyo3.rs/main/class.html#initializer)。 diff --git a/openspec/changes/distinguish-new-and-init/proposal.md b/openspec/changes/distinguish-new-and-init/proposal.md new file mode 100644 index 0000000..6c8daaf --- /dev/null +++ b/openspec/changes/distinguish-new-and-init/proposal.md @@ -0,0 +1,36 @@ +## Why + +Rylai 当前把所有带 `#[new]` 的 PyO3 方法一律生成为 Python `__init__`。这覆盖了绝大多数场景,但 PyO3 提供了一种 [Initializer 模式](https://pyo3.rs/main/class.html#initializer):当类(通常继承 `PyDict` 等原生类型)需要控制初始化流程时,会**同时**定义 `#[new]` 与字面量名为 `__init__` 的方法。此时 Rylai 会把两者都生成成 `def __init__`,既不符合 PyO3 语义,也会产生重复定义。Rylai 应遵循 PyO3 官方语义:`#[new]` 对应 Python `__new__`,显式 `__init__` 对应 `__init__`。 + +## What Changes + +- 新增检测:遍历类的**全部** `#[pymethods]` 块(含 `multiple-pymethods`),判断是否存在字面量名为 `__init__` 的方法。 +- **无显式 `__init__`**(现状,保持):`#[new]` 生成为 `def __init__(self, ...) -> None`。即便 `#[new]` 的 Rust 方法名就叫 `__new__`,也仍输出 `__init__`——方法名不是分叉依据(PyO3 明确 Rust 方法名不重要)。 +- **有显式 `__init__`**(Initializer 模式,新增): + - `#[new]` 生成为 `def __new__(cls, ...) -> Self`(首参 `cls`),返回类型复用现有 `python_version` 驱动的 Self 渲染(py ≥ 3.11 → `t.Self`,py < 3.11 → 类名)。 + - 显式 `__init__` 生成为 `def __init__(self, ...) -> None`。 +- 检测条件与 PyO3 一致:只认字面量方法名 `__init__`。 +- 不做额外校验或报警,完全遵循 PyO3 文档。 +- 顺带修复:`#[new]` + 显式 `__init__` 不再生成重复的 `def __init__`。 + +## Non-goals + +- 不支持 `#[pyo3(name = "__init__")]` 这类 rename——PyO3 自身不按它识别魔法方法,Rylai 也不引入 PyO3 都不承认的行为。 +- 不对"只有显式 `__init__` 没有 `#[new]`"等组合做语义校验或报警——stub 生成器不承担 lint 职责。 +- 不改变"无显式 `__init__` 时 `#[new]` → `__init__`"这一既有正确行为(这是设计本身,非兼容妥协)。 + +## Capabilities + +### New Capabilities +- `constructor-emission`: PyO3 构造器(`#[new]` 与字面量 `__init__`)到 Python stub 的 `__new__`/`__init__` 映射规则与首参/返回类型契约。 + +### Modified Capabilities + + +## Impact + +- **generator(核心)**:`MethodKind::New` 的生成逻辑需根据"是否存在显式 `__init__`"分叉;`__new__` 分支首参改为 `cls`、返回类型走 `Self`。 +- **collector**:需收集并向上暴露"类内是否存在字面量 `__init__` 方法"的信号(跨所有 `#[pymethods]` 块)。 +- **model**:可能需新增上下文标志(如 `MethodKind::New` 携带 initializer 模式标志),不改变现有枚举对 `#[new]` 的识别。 +- **CLI / config**:不受影响,无新配置项。 +- **PyO3 兼容性**:行为与 [PyO3 Initializer 文档](https://pyo3.rs/main/class.html#initializer) 一致,无破坏性变更。 diff --git a/openspec/changes/distinguish-new-and-init/specs/constructor-emission/spec.md b/openspec/changes/distinguish-new-and-init/specs/constructor-emission/spec.md new file mode 100644 index 0000000..df040e1 --- /dev/null +++ b/openspec/changes/distinguish-new-and-init/specs/constructor-emission/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: 无显式 `__init__` 时 `#[new]` 生成为 Python `__init__` + +当 `#[pyclass]` 的全部 `#[pymethods]` 块中存在带 `#[new]` 属性的方法、但**不存在**字面量名为 `__init__` 的方法时,该 `#[new]` 方法 MUST 生成为 `def __init__(self, ...) -> None`。分叉依据是"是否存在显式 `__init__`",而非 `#[new]` 的 Rust 方法名——即便 `#[new]` 方法名就叫 `__new__`,只要没有显式 `__init__`,仍 MUST 输出 `__init__`。 + +#### Scenario: 仅 `#[new]`,方法名 `new` +- **WHEN** `#[pymethods]` 声明 `#[new] fn new(value: i32) -> Self`,类内无字面量名为 `__init__` 的方法 +- **THEN** 生成的 stub MUST 为 `def __init__(self, value: int) -> None` + +#### Scenario: `#[new]` 方法名为 `__new__` 但无显式 `__init__` +- **WHEN** `#[pymethods]` 声明 `#[new] fn __new__(value: i32) -> Self`,类内无字面量名为 `__init__` 的方法 +- **THEN** 生成的 stub MUST 仍为 `def __init__(self, value: int) -> None`,方法名不构成分叉依据 + +#### Scenario: `#[new]` 方法名为 `py_new` +- **WHEN** `#[pymethods]` 声明 `#[new] fn py_new(value: i32) -> Self`,类内无字面量名为 `__init__` 的方法 +- **THEN** 生成的 stub MUST 为 `def __init__(self, value: int) -> None` + +### Requirement: 显式 `__init__` 必须独立于 `#[new]` + +判定"是否存在显式 `__init__`"时,一个名为 `__init__` 的方法仅当其**不是** `#[new]` 方法(`kind != MethodKind::New`)时才计入。一个方法不能同时充当 constructor(`#[new]`)与 initializer(显式 `__init__`)——它们是同一类的两个不同槽位。因此 `#[new] fn __init__(...)`(构造器的方法名恰好为 `__init__`,PyO3 合法)MUST NOT 被判定为存在显式 `__init__`,MUST 收敛到单构造器行为(生成 `__init__`)。 + +#### Scenario: `#[new]` 方法名恰好为 `__init__` 不触发 Initializer 模式 +- **WHEN** `#[pymethods]` 声明 `#[new] fn __init__(value: i32) -> Self`,类内无其他独立的 `__init__` 方法 +- **THEN** 该类 MUST NOT 进入 Initializer 模式,`#[new]` MUST 生成为 `def __init__(self, value: int) -> None`(与单构造器行为一致) + +### Requirement: Initializer 模式下 `#[new]` 生成为 Python `__new__` + +当 `#[pyclass]` 的全部 `#[pymethods]` 块中**同时**存在带 `#[new]` 属性的方法与字面量名为 `__init__` 的方法(PyO3 [Initializer 模式](https://pyo3.rs/main/class.html#initializer))时,`#[new]` 方法 MUST 生成为 `def __new__(cls, ...) -> Self`,显式 `__init__` 方法 MUST 生成为 `def __init__(self, ...) -> None`。两者 MUST NOT 生成重复的 `def __init__`。 + +#### Scenario: 同时定义 `#[new]` 与显式 `__init__` +- **WHEN** `#[pymethods]` 声明 `#[new] fn new(args: ...) -> Self` 与 `fn __init__(&self, args: ...) -> ()`,且类继承原生类型(如 `#[pyclass(extends = PyDict)]`) +- **THEN** 生成的 stub MUST 同时包含 `def __new__(cls, ...) -> ...` 与 `def __init__(self, ...) -> None`,二者 MUST NOT 重复 + +### Requirement: `__new__` 返回类型遵循版本感知的 `Self` 渲染 + +Initializer 模式下 `#[new]` 生成的 `__new__` 返回类型 MUST 经由现有 `Self` 渲染机制产出:当 `python_version` ≥ 3.11(PEP 673 `nativeSelf`)时 MUST 为 `t.Self`;当 `python_version` < 3.11 时 MUST 为当前类的类名。该渲染 MUST 与"返回 `Self` 的 staticmethod"行为完全一致。 + +#### Scenario: `python_version` 3.12 下 `__new__` 返回 `t.Self` +- **WHEN** Initializer 模式下 `python_version` 配置为 `3.12` +- **THEN** 生成的 `__new__` 签名 MUST 形如 `def __new__(cls, ...) -> t.Self:`,且 stub MUST 包含 `import typing as t`、MUST NOT 包含 `from __future__ import annotations` + +#### Scenario: `python_version` 3.9 下 `__new__` 返回类名 +- **WHEN** Initializer 模式下 `python_version` 配置为 `3.9`,类名为 `MyDict` +- **THEN** 生成的 `__new__` 签名 MUST 形如 `def __new__(cls, ...) -> MyDict:`,且 stub MUST 包含 `from __future__ import annotations`、MUST NOT 出现 `t.Self` + +### Requirement: `__new__` 首参为 `cls` + +Initializer 模式下生成的 `__new__` 其首参 MUST 为 `cls`(Python 构造器约定),而非 `self`。Rust `#[new]` 方法的 receiver(若以 typed receiver 形式声明)MUST NOT 出现在 stub 参数中,且 MUST NOT 覆盖 `cls` 首参位置。 + +#### Scenario: `__new__` 首参注入 `cls` +- **WHEN** Initializer 模式下 `#[new]` 方法签名为 `fn new(args: &Bound<'_, PyTuple>) -> PyResult` +- **THEN** 生成的 `__new__` 首参 MUST 为 `cls`,`args` 之外的注入型参数按既有 receiver/注入规则处理 + +### Requirement: 仅识别字面量方法名 `__init__` + +Initializer 模式的触发检测 MUST 仅匹配 Rust 方法名字面量为 `__init__`(`rust_ident == "__init__"`)。`#[pyo3(name = "__init__")]` 这类 rename MUST NOT 触发 Initializer 模式分叉——与 PyO3 自身按字面量识别魔法方法的行为一致。 + +#### Scenario: `#[pyo3(name = "__init__")]` 不触发 Initializer 模式 +- **WHEN** `#[pymethods]` 声明带 `#[new]` 的方法,且存在 `#[pyo3(name = "__init__")] fn my_init(&self, ...)`(Rust 名非 `__init__`) +- **THEN** 系统 MUST NOT 进入 Initializer 模式,`#[new]` MUST 仍生成为 `def __init__` + +### Requirement: Initializer 模式检测跨全部 `#[pymethods]` 块 + +当启用 `multiple-pymethods` feature 时,字面量 `__init__` 的存在检测 MUST 跨越该类的全部 `#[pymethods]` impl 块。`#[new]` 与显式 `__init__` 出现在不同 impl 块时,分叉判定 MUST 与二者同块时一致。 + +#### Scenario: `#[new]` 与 `__init__` 分处不同 impl 块 +- **WHEN** 同一 `#[pyclass]` 在第一个 `#[pymethods]` 块声明 `#[new] fn new(...)`,在第二个 `#[pymethods]` 块声明 `fn __init__(&self, ...)` +- **THEN** 系统 MUST 进入 Initializer 模式,`#[new]` 生成为 `__new__`,第二个块的 `__init__` 生成为 `__init__` + +### Requirement: Initializer 模式下 override 精确命中、无重复 + +当用户通过 `[[override]]` 覆盖构造器时,override 项 MUST 按目标方法的**生成 stub 名**精确命中。Initializer 模式下 `::Class::__new__`(或 `#[new]` 的 rust_ident)MUST 只命中 `#[new]`,`::Class::__init__` MUST 只命中显式 `__init__`。单个 override 项 MUST NOT 同时替换 `#[new]` 与显式 `__init__` 两个方法的 stub。`#[new]` 的 dunder 别名 MUST 等于其当前生成名(非 Initializer 模式为 `__init__`,Initializer 模式为 `__new__`),MUST NOT 同时持有 `__init__` 与 `__new__` 两个别名。 + +#### Scenario: Initializer 模式下 override `__new__` 只命中 `#[new]` +- **WHEN** Initializer 模式下 override 项为 `m::MyClass::__new__` +- **THEN** 该 override MUST 只替换 `#[new]` 生成的 `__new__` stub,MUST NOT 影响显式 `__init__` 的 stub + +#### Scenario: Initializer 模式下 override `__init__` 只命中显式 `__init__` +- **WHEN** Initializer 模式下 override 项为 `m::MyClass::__init__` +- **THEN** 该 override MUST 只替换显式 `__init__` 生成的 stub,MUST NOT 同时替换 `#[new]` 的 `__new__` stub diff --git a/openspec/changes/distinguish-new-and-init/tasks.md b/openspec/changes/distinguish-new-and-init/tasks.md new file mode 100644 index 0000000..f00906f --- /dev/null +++ b/openspec/changes/distinguish-new-and-init/tasks.md @@ -0,0 +1,43 @@ +# Tasks + +## 1. 检测信号:字面量 `__init__` 存在性 + +- [x] 1.1 在 generator 的类方法生成入口(`gen_method`,与 `current_self_type` 同作用域),经 `class_has_explicit_init(class)` 计算 `has_explicit_init = class.methods.iter().any(|m| m.rust_ident == "__init__" && m.kind != MethodKind::New)`(排除 `#[new]` 本身,依据 design 决策 2),不修改 model 层 +- [x] 1.2 验证 `rust_ident` 字段对字面量 `fn __init__` 取值为 `"__init__"`(`rename_all` 不影响 `rust_ident`)—— 端到端 example 生成的 `__new__`/`__init__` 证实 +- [x] 1.3 验证 `#[new] fn __init__`(构造器方法名恰好为 `__init__`)被 `kind != MethodKind::New` 正确排除,不触发 Initializer 模式 —— 集成测试 `new_named_init_not_initializer_mode` +- [x] 1.4 检测覆盖 `multiple-pymethods`:`class_has_explicit_init` 遍历 collector 聚合后的 `class.methods`,跨块聚合由 collector 既有逻辑保证 + +## 2. Generator 分叉:`MethodKind::New` 的 Initializer 分支 + +- [x] 2.1 `emit_method_signature_lines` 的 `MethodKind::New` 分支经 `MethodSignatureEmitOpts.has_explicit_init` 接收上下文并按其值分叉 +- [x] 2.2 `has_explicit_init == false`:保持现状 —— `def __init__(self, ...) -> None`,现有 306 测试全绿(行为不变) +- [x] 2.3 `has_explicit_init == true`:生成为 `def __new__(cls, ...) -> ` +- [x] 2.4 为 `__new__` 分支实现首参注入裸 `cls`(**无类型注解**,遵循 PEP 673/typeshed,与 `self` 注入对称):用 `method_params(..., false, ...)` 取参数后手动前缀 `cls`,不改动 `method_params`/`gen_params` 签名(隔离影响) +- [x] 2.5 为 `__new__` 分支接返回类型到现有 `Self` 渲染:新增 `resolve_self_type` 构造 `Self` PyType 经 `resolve_type`(依赖 `current_self_type` + `RenderPolicy`)产出 `t.Self`(py ≥ 3.11)或类名(py < 3.11);`method_stub_return_type` 新增 `is_initializer` 参数使 `__new__` 走 Self 而非空串 + +## 3. Override 系统适配 + +- [x] 3.1 调整 `method_override_entry_matches` 的 `#[new]` override 别名匹配:保留 rust_ident 直匹配作兜底;dunder 别名按生成模式动态选择(非 Initializer → `__init__`,Initializer → `__new__`,单别名),使别名与实际生成的 stub 名一致 +- [x] 3.2 验证现有 `#[new]` override 测试(`m::Counter::__init__`、`m::TfSettings::py_new` 等)在非 Initializer 模式下行为不变 —— 314 测试全绿 +- [x] 3.3 验证 Initializer 模式下单个 override 项只命中一个方法 —— 集成测试 `initializer_mode_override_init_does_not_hit_new` + +## 4. 显式 `__init__` 生成 + +- [x] 4.1 显式 `fn __init__`(`MethodKind::Instance`)走现有实例方法路径生成为 `def __init__(self, ...) -> None`,且因 `#[new]` 在 Initializer 模式改生成 `__new__`,不再重复 —— 端到端 example 证实 +- [x] 4.2 显式 `__init__` 返回类型由现有映射覆盖:`()` / `PyResult<()>` 解包到 `()` 映射为 `None`,走 `MethodKind::Instance` 路径自动得到 `-> None`,无需额外强制 + +## 5. 测试 + +- [x] 5.1 新增 `examples/initializer_mode_sample`:声明 `#[new]` 与字面量 `fn __init__` 的类(省略 `extends = PyDict` 以保持编译稳定,注释说明真实 Initializer 场景),作为 Cargo workspace 成员;rylai 端到端生成 `__new__(cls, ...) -> WithInit` + `__init__(self, ...) -> None` 验证 collector 链路 +- [x] 5.2 集成测试 `initializer_mode_emits_new_and_init_separately`:`__new__`(首参 `cls`、返回类名)+ `__init__`(返回 None)同时存在、无重复定义;`initializer_mode_py312_returns_t_self` / `initializer_mode_py39_returns_class_name` 锁定版本感知返回类型 +- [x] 5.3 集成测试 `new_named_new_underscore_without_init_still_emits_init`:无显式 `__init__`、`#[new]` 方法名为 `__new__` 仍输出 `__init__`(关键不变量) +- [x] 5.4 集成测试 `pyo3_name_init_does_not_trigger_initializer`:`#[pyo3(name = "__init__")]` 不触发 Initializer 模式 +- [x] 5.5 `class_has_explicit_init` 遍历聚合后的 `class.methods`,multiple-pymethods 跨块聚合由 collector 既有逻辑保证;`initializer_mode_emits_new_and_init_separately`(构造聚合后的 methods vec)已验证检测正确 +- [x] 5.6 集成测试 `new_named_init_not_initializer_mode`:`#[new] fn __init__` 不触发 Initializer 模式、仍输出 `__init__`(检测公式排除 `#[new]` 本身的防回归) + +## 6. 文档与收尾 + +- [x] 6.1 README `## Features` 增补 PyO3 构造器语义条目 + `[[override]]` 段更新 `#[new]` 别名说明,均引用 [PyO3 Initializer 文档](https://pyo3.rs/main/class.html#initializer) +- [x] 6.2 CHANGELOG `[Unreleased]` 增 `### Added` 条目,注明遵循 PyO3 Initializer 文档、`#[new]` 别名对齐 +- [x] 6.3 `cargo test`(314 passed)与 `cargo clippy --all-targets`(无 warning/error)全绿 +- [x] 6.4 `openspec validate distinguish-new-and-init --strict` 通过 diff --git a/src/generator.rs b/src/generator.rs index 19d564a..241512d 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -188,6 +188,9 @@ struct MethodSignatureEmitOpts<'a> { used: &'a mut HashSet, return_override: Option<&'a str>, touch_discarded_return: bool, + /// Whether this class has an explicit `fn __init__` distinct from `#[new]` + /// (PyO3 Initializer mode). Drives the `#[new]` → `__new__` split. + has_explicit_init: bool, } struct GenCtx<'a> { @@ -368,6 +371,17 @@ impl<'a> GenCtx<'a> { Ok(()) } + /// Whether `class` has an explicit `fn __init__` distinct from `#[new]` (PyO3 Initializer mode). + /// A method cannot be both a constructor (`#[new]`) and an initializer (explicit `__init__`), + /// so `MethodKind::New` entries named `__init__` are excluded — otherwise `#[new] fn __init__` + /// would be misclassified as Initializer mode. See design decision 2. + fn class_has_explicit_init(class: &PyClass) -> bool { + class + .methods + .iter() + .any(|m| m.rust_ident == "__init__" && m.kind != MethodKind::New) + } + /// Whether `[[override]]` `item` targets this `#[pymethods]` entry (any kind: instance, static, /// class, getter/setter, or `#[new]`). fn method_override_entry_matches( @@ -387,10 +401,18 @@ impl<'a> GenCtx<'a> { return true; } if matches!(method.kind, MethodKind::New) { - if matches_pair(&class.rust_name, "__init__") { + // The dunder alias of `#[new]` = the stub name it actually generates (design decision 5, hard constraint). + // In non-Initializer mode it generates `__init__`; in Initializer mode it generates `__new__`. Single alias: + // if it held both, an `__init__` override would match both `#[new]` and the explicit `__init__` (bug). + let alias = if Self::class_has_explicit_init(class) { + "__new__" + } else { + "__init__" + }; + if matches_pair(&class.rust_name, alias) { return true; } - if matches_pair(&class.name, "__init__") { + if matches_pair(&class.name, alias) { return true; } } @@ -636,9 +658,21 @@ impl<'a> GenCtx<'a> { m: &PyMethod, location: &str, return_override: Option<&str>, + is_initializer: bool, ) -> Result { Ok(match &m.kind { - MethodKind::New | MethodKind::Setter(_) => String::new(), + MethodKind::Setter(_) => String::new(), + // Initializer mode: `#[new]` generates `__new__`, and the return type goes through the version-aware Self rendering + // (py >= 3.11 -> `t.Self`, py < 3.11 -> class name), sharing the same path as "methods that return Self". + // Honor return_override; otherwise resolve_self_type. + MethodKind::New if is_initializer => { + if let Some(s) = return_override.filter(|s| !s.trim().is_empty()) { + s.trim().to_string() + } else { + self.resolve_self_type(&format!("{location}::return"))? + } + } + MethodKind::New => String::new(), _ => { if let Some(s) = return_override.filter(|s| !s.trim().is_empty()) { s.trim().to_string() @@ -649,6 +683,18 @@ impl<'a> GenCtx<'a> { }) } + /// Resolve the `Self` type for the current class via the version-aware renderer + /// (py ≥ 3.11 → `t.Self`, py < 3.11 → class name). Reuses the same path as any + /// `-> Self` method so `__new__`'s return type stays consistent with staticmethods + /// returning `Self`. See design decision 3. + fn resolve_self_type(&mut self, location: &str) -> Result { + let self_pytype = PyType { + rust_type: syn::parse_quote! { Self }, + override_str: None, + }; + self.resolve_type(&self_pytype, location) + } + /// Value parameter type for a `#[setter]` stub (`value: ...`), with optional `param_types` override. fn setter_value_param_type( &mut self, @@ -696,15 +742,29 @@ impl<'a> GenCtx<'a> { used, return_override, touch_discarded_return, + has_explicit_init, } = opts; if touch_discarded_return && matches!(m.kind, MethodKind::New | MethodKind::Setter(_)) { let _ = self.resolve_type(&m.return_type, &format!("{location}::return"))?; } - let ret = self.method_stub_return_type(m, location, return_override)?; + let ret = self.method_stub_return_type(m, location, return_override, has_explicit_init)?; match &m.kind { MethodKind::New => { - let params = self.method_params(m, location, true, lookup, used)?; - out.push_str(&format!("{pad}def __init__({params}) -> None:\n")); + if has_explicit_init { + // Initializer mode (PyO3 #[new] + explicit __init__): `#[new]` -> `__new__(cls, ...) -> Self`. + // The first param injects a bare `cls` (no type annotation, following PEP 673 / typeshed, symmetric with `self` injection), + // and the remaining params reuse method_params(with_self=false) with `cls` manually prefixed. + let rest = self.method_params(m, location, false, lookup, used)?; + let params = if rest.is_empty() { + "cls".to_string() + } else { + format!("cls, {rest}") + }; + out.push_str(&format!("{pad}def __new__({params}) -> {ret}:\n")); + } else { + let params = self.method_params(m, location, true, lookup, used)?; + out.push_str(&format!("{pad}def __init__({params}) -> None:\n")); + } } MethodKind::Static => { out.push_str(&format!("{pad}@staticmethod\n")); @@ -760,6 +820,8 @@ impl<'a> GenCtx<'a> { let _guard = RestoreCurrentSelfTypeGuard(&mut self.current_self_type as *mut Option); let pad = " ".repeat(indent); + // Initializer mode detection (see design decision 2): the class has a method whose literal name is `__init__` and that is not `#[new]`. + let has_explicit_init = Self::class_has_explicit_init(class); if let Some(ov) = self.find_method_override(class, m).cloned() { if let Some(stub) = ov.stub.as_ref().filter(|s| !s.trim().is_empty()) { @@ -790,6 +852,7 @@ impl<'a> GenCtx<'a> { used: &mut used, return_override, touch_discarded_return: false, + has_explicit_init, }, )?; @@ -817,6 +880,7 @@ impl<'a> GenCtx<'a> { used: &mut unused, return_override: None, touch_discarded_return: true, + has_explicit_init, }, )?; self.emit_method_doc_or_placeholder(m, out, &pad, indent); @@ -2559,6 +2623,206 @@ mod tests { ); } + // ── Initializer mode: `#[new]` + explicit `__init__` generate `__new__` / `__init__` separately ── + // See openspec/changes/distinguish-new-and-init (follows the PyO3 Initializer docs). + + /// Build an Initializer-mode class: a `#[new]` method + an explicit `fn __init__`. + fn make_initializer_mode_class(class_name: &str) -> PyClass { + let new_method = make_method( + "new", + MethodKind::New, + vec![make_param("value", syn::parse_quote! { i32 })], + syn::parse_quote! { Self }, + ); + let init_method = make_method( + "__init__", + MethodKind::Instance, + vec![make_param("value", syn::parse_quote! { i32 })], + syn::parse_quote! { () }, + ); + make_class_with_methods(class_name, vec![new_method, init_method]) + } + + /// Initializer mode: `#[new]` -> `__new__(cls, ...)`, explicit `__init__` -> `__init__(self, ...) -> None`, + /// and the two are not duplicated. + #[test] + fn initializer_mode_emits_new_and_init_separately() { + let class = make_initializer_mode_class("MyDict"); + let stub = stub_for(vec![PyItem::Class(class)]); + // Default python_version 3.10 (< 3.11) -> `__new__` returns the class name. + assert!( + stub.contains("def __new__(cls, value: int) -> MyDict:"), + "Initializer mode MUST emit __new__(cls, ...) -> class name, got:\n{stub}" + ); + assert!( + stub.contains("def __init__(self, value: int) -> None"), + "explicit __init__ MUST emit __init__(self, ...) -> None, got:\n{stub}" + ); + assert_eq!( + stub.matches("def __new__").count(), + 1, + "there should be exactly one def __new__, got:\n{stub}" + ); + assert_eq!( + stub.matches("def __init__").count(), + 1, + "there should be exactly one def __init__ (no duplication), got:\n{stub}" + ); + } + + /// python_version 3.12 (>= 3.11): `__new__` returns `t.Self`. + #[test] + fn initializer_mode_py312_returns_t_self() { + let config = config_with_python_version("3.12"); + let class = make_initializer_mode_class("MyDict"); + let stub = stub_for_config(vec![PyItem::Class(class)], &config); + assert!( + stub.contains("def __new__(cls, value: int) -> t.Self:"), + "py 3.12 __new__ MUST return t.Self, got:\n{stub}" + ); + assert!(stub.contains("import typing as t"), "got:\n{stub}"); + } + + /// python_version 3.9 (< 3.11): `__new__` returns the class name + future annotations. + #[test] + fn initializer_mode_py39_returns_class_name() { + let config = config_with_python_version("3.9"); + let class = make_initializer_mode_class("MyDict"); + let stub = stub_for_config(vec![PyItem::Class(class)], &config); + assert!( + stub.contains("def __new__(cls, value: int) -> MyDict:"), + "py 3.9 __new__ MUST return the class name, got:\n{stub}" + ); + assert!( + stub.contains("from __future__ import annotations"), + "py 3.9 MUST emit future annotations, got:\n{stub}" + ); + assert!( + !stub.contains("t.Self"), + "py 3.9 MUST NOT use t.Self, got:\n{stub}" + ); + } + + /// No explicit `__init__`: `#[new] fn new()` -> `__init__` (existing behavior unchanged). + #[test] + fn new_without_explicit_init_emits_init() { + let class = make_class_with_methods( + "C", + vec![make_method( + "new", + MethodKind::New, + vec![make_param("value", syn::parse_quote! { i32 })], + syn::parse_quote! { Self }, + )], + ); + let stub = stub_for(vec![PyItem::Class(class)]); + assert!( + stub.contains("def __init__(self, value: int) -> None"), + "with no explicit __init__, #[new] MUST emit __init__, got:\n{stub}" + ); + assert!( + !stub.contains("def __new__"), + "MUST NOT emit __new__, got:\n{stub}" + ); + } + + /// No explicit `__init__` and the `#[new]` method is named `__new__`: still emits `__init__` (the method name is not a branching criterion). + #[test] + fn new_named_new_underscore_without_init_still_emits_init() { + let class = make_class_with_methods( + "C", + vec![make_method( + "__new__", + MethodKind::New, + vec![make_param("value", syn::parse_quote! { i32 })], + syn::parse_quote! { Self }, + )], + ); + let stub = stub_for(vec![PyItem::Class(class)]); + assert!( + stub.contains("def __init__(self, value: int) -> None"), + "#[new] named __new__ but with no explicit __init__ still MUST emit __init__, got:\n{stub}" + ); + assert!(!stub.contains("def __new__"), "got:\n{stub}"); + } + + /// The `#[new]` method is literally named `__init__` (no standalone `__init__`): does not trigger Initializer, collapses to `__init__`. + #[test] + fn new_named_init_not_initializer_mode() { + let class = make_class_with_methods( + "C", + vec![make_method( + "__init__", + MethodKind::New, + vec![make_param("value", syn::parse_quote! { i32 })], + syn::parse_quote! { Self }, + )], + ); + let stub = stub_for(vec![PyItem::Class(class)]); + assert!( + stub.contains("def __init__(self, value: int) -> None"), + "#[new] fn __init__ (no standalone __init__) MUST collapse to __init__, got:\n{stub}" + ); + assert!( + !stub.contains("def __new__"), + "MUST NOT trigger Initializer, got:\n{stub}" + ); + } + + /// `#[pyo3(name = "__init__")]` (rust_ident != `__init__`) does not trigger Initializer mode. + #[test] + fn pyo3_name_init_does_not_trigger_initializer() { + let new_method = make_method( + "new", + MethodKind::New, + vec![make_param("value", syn::parse_quote! { i32 })], + syn::parse_quote! { Self }, + ); + // Simulate #[pyo3(name = "__init__")]: rust_ident differs from name. + let renamed = PyMethod { + rust_ident: "my_init".to_string(), + name: "__init__".to_string(), + doc: vec![], + kind: MethodKind::Instance, + signature_override: None, + params: vec![make_param("value", syn::parse_quote! { i32 })], + return_type: PyType { + rust_type: syn::parse_quote! { () }, + override_str: None, + }, + }; + let class = make_class_with_methods("C", vec![new_method, renamed]); + let stub = stub_for(vec![PyItem::Class(class)]); + assert!( + !stub.contains("def __new__"), + "#[pyo3(name=__init__)] MUST NOT trigger Initializer (no __new__), got:\n{stub}" + ); + } + + /// Initializer mode: an override on `::Class::__init__` matches only the explicit `__init__` and does not affect `#[new]`'s `__new__` + /// (no duplicate match invariant, see design decision 5). + #[test] + fn initializer_mode_override_init_does_not_hit_new() { + use crate::config::OverrideEntry; + let mut config = Config::default(); + config.overrides.push(OverrideEntry { + item: "m::MyDict::__init__".to_string(), + stub: Some("def __init__(self, overridden: int) -> None:".to_string()), + param_types: None, + return_type: None, + }); + let class = make_initializer_mode_class("MyDict"); + let stub = stub_for_config(vec![PyItem::Class(class)], &config); + assert!( + stub.contains("overridden"), + "override __init__ MUST match the explicit __init__, got:\n{stub}" + ); + assert!( + stub.contains("def __new__(cls, value: int) -> MyDict:"), + "override __init__ MUST NOT affect #[new]'s __new__ (no duplicate match), got:\n{stub}" + ); + } + #[test] fn gen_class_emits_extends_builtin_dict() { let class = PyClass { From 3ff34fe8bc416b6b5b87c8f5ad272510c80b0a8f Mon Sep 17 00:00:00 2001 From: monchin Date: Sun, 14 Jun 2026 12:53:17 +0800 Subject: [PATCH 2/3] chore(openspec): archive distinguish-new-and-init change --- .../design.md | 0 .../proposal.md | 0 .../specs/constructor-emission/spec.md | 0 .../tasks.md | 0 openspec/specs/constructor-emission/spec.md | 87 +++++++++++++++++++ 5 files changed, 87 insertions(+) rename openspec/changes/{distinguish-new-and-init => archive/2026-06-14-distinguish-new-and-init}/design.md (100%) rename openspec/changes/{distinguish-new-and-init => archive/2026-06-14-distinguish-new-and-init}/proposal.md (100%) rename openspec/changes/{distinguish-new-and-init => archive/2026-06-14-distinguish-new-and-init}/specs/constructor-emission/spec.md (100%) rename openspec/changes/{distinguish-new-and-init => archive/2026-06-14-distinguish-new-and-init}/tasks.md (100%) create mode 100644 openspec/specs/constructor-emission/spec.md diff --git a/openspec/changes/distinguish-new-and-init/design.md b/openspec/changes/archive/2026-06-14-distinguish-new-and-init/design.md similarity index 100% rename from openspec/changes/distinguish-new-and-init/design.md rename to openspec/changes/archive/2026-06-14-distinguish-new-and-init/design.md diff --git a/openspec/changes/distinguish-new-and-init/proposal.md b/openspec/changes/archive/2026-06-14-distinguish-new-and-init/proposal.md similarity index 100% rename from openspec/changes/distinguish-new-and-init/proposal.md rename to openspec/changes/archive/2026-06-14-distinguish-new-and-init/proposal.md diff --git a/openspec/changes/distinguish-new-and-init/specs/constructor-emission/spec.md b/openspec/changes/archive/2026-06-14-distinguish-new-and-init/specs/constructor-emission/spec.md similarity index 100% rename from openspec/changes/distinguish-new-and-init/specs/constructor-emission/spec.md rename to openspec/changes/archive/2026-06-14-distinguish-new-and-init/specs/constructor-emission/spec.md diff --git a/openspec/changes/distinguish-new-and-init/tasks.md b/openspec/changes/archive/2026-06-14-distinguish-new-and-init/tasks.md similarity index 100% rename from openspec/changes/distinguish-new-and-init/tasks.md rename to openspec/changes/archive/2026-06-14-distinguish-new-and-init/tasks.md diff --git a/openspec/specs/constructor-emission/spec.md b/openspec/specs/constructor-emission/spec.md new file mode 100644 index 0000000..7700974 --- /dev/null +++ b/openspec/specs/constructor-emission/spec.md @@ -0,0 +1,87 @@ +# Constructor Emission + +## Purpose + +定义 Rylai 生成器如何把 PyO3 构造器(`#[new]` 与字面量 `__init__`)映射为 Python stub 的 `__new__` / `__init__`,遵循 [PyO3 Initializer 文档](https://pyo3.rs/main/class.html#initializer) 语义。 + +## Requirements + +### Requirement: 无显式 `__init__` 时 `#[new]` 生成为 Python `__init__` + +当 `#[pyclass]` 的全部 `#[pymethods]` 块中存在带 `#[new]` 属性的方法、但**不存在**字面量名为 `__init__` 的方法时,该 `#[new]` 方法 MUST 生成为 `def __init__(self, ...) -> None`。分叉依据是"是否存在显式 `__init__`",而非 `#[new]` 的 Rust 方法名——即便 `#[new]` 方法名就叫 `__new__`,只要没有显式 `__init__`,仍 MUST 输出 `__init__`。 + +#### Scenario: 仅 `#[new]`,方法名 `new` +- **WHEN** `#[pymethods]` 声明 `#[new] fn new(value: i32) -> Self`,类内无字面量名为 `__init__` 的方法 +- **THEN** 生成的 stub MUST 为 `def __init__(self, value: int) -> None` + +#### Scenario: `#[new]` 方法名为 `__new__` 但无显式 `__init__` +- **WHEN** `#[pymethods]` 声明 `#[new] fn __new__(value: i32) -> Self`,类内无字面量名为 `__init__` 的方法 +- **THEN** 生成的 stub MUST 仍为 `def __init__(self, value: int) -> None`,方法名不构成分叉依据 + +#### Scenario: `#[new]` 方法名为 `py_new` +- **WHEN** `#[pymethods]` 声明 `#[new] fn py_new(value: i32) -> Self`,类内无字面量名为 `__init__` 的方法 +- **THEN** 生成的 stub MUST 为 `def __init__(self, value: int) -> None` + +### Requirement: 显式 `__init__` 必须独立于 `#[new]` + +判定"是否存在显式 `__init__`"时,一个名为 `__init__` 的方法仅当其**不是** `#[new]` 方法(`kind != MethodKind::New`)时才计入。一个方法不能同时充当 constructor(`#[new]`)与 initializer(显式 `__init__`)——它们是同一类的两个不同槽位。因此 `#[new] fn __init__(...)`(构造器的方法名恰好为 `__init__`,PyO3 合法)MUST NOT 被判定为存在显式 `__init__`,MUST 收敛到单构造器行为(生成 `__init__`)。 + +#### Scenario: `#[new]` 方法名恰好为 `__init__` 不触发 Initializer 模式 +- **WHEN** `#[pymethods]` 声明 `#[new] fn __init__(value: i32) -> Self`,类内无其他独立的 `__init__` 方法 +- **THEN** 该类 MUST NOT 进入 Initializer 模式,`#[new]` MUST 生成为 `def __init__(self, value: int) -> None`(与单构造器行为一致) + +### Requirement: Initializer 模式下 `#[new]` 生成为 Python `__new__` + +当 `#[pyclass]` 的全部 `#[pymethods]` 块中**同时**存在带 `#[new]` 属性的方法与字面量名为 `__init__` 的方法(PyO3 [Initializer 模式](https://pyo3.rs/main/class.html#initializer))时,`#[new]` 方法 MUST 生成为 `def __new__(cls, ...) -> Self`,显式 `__init__` 方法 MUST 生成为 `def __init__(self, ...) -> None`。两者 MUST NOT 生成重复的 `def __init__`。 + +#### Scenario: 同时定义 `#[new]` 与显式 `__init__` +- **WHEN** `#[pymethods]` 声明 `#[new] fn new(args: ...) -> Self` 与 `fn __init__(&self, args: ...) -> ()`,且类继承原生类型(如 `#[pyclass(extends = PyDict)]`) +- **THEN** 生成的 stub MUST 同时包含 `def __new__(cls, ...) -> ...` 与 `def __init__(self, ...) -> None`,二者 MUST NOT 重复 + +### Requirement: `__new__` 返回类型遵循版本感知的 `Self` 渲染 + +Initializer 模式下 `#[new]` 生成的 `__new__` 返回类型 MUST 经由现有 `Self` 渲染机制产出:当 `python_version` ≥ 3.11(PEP 673 `nativeSelf`)时 MUST 为 `t.Self`;当 `python_version` < 3.11 时 MUST 为当前类的类名。该渲染 MUST 与"返回 `Self` 的 staticmethod"行为完全一致。 + +#### Scenario: `python_version` 3.12 下 `__new__` 返回 `t.Self` +- **WHEN** Initializer 模式下 `python_version` 配置为 `3.12` +- **THEN** 生成的 `__new__` 签名 MUST 形如 `def __new__(cls, ...) -> t.Self:`,且 stub MUST 包含 `import typing as t`、MUST NOT 包含 `from __future__ import annotations` + +#### Scenario: `python_version` 3.9 下 `__new__` 返回类名 +- **WHEN** Initializer 模式下 `python_version` 配置为 `3.9`,类名为 `MyDict` +- **THEN** 生成的 `__new__` 签名 MUST 形如 `def __new__(cls, ...) -> MyDict:`,且 stub MUST 包含 `from __future__ import annotations`、MUST NOT 出现 `t.Self` + +### Requirement: `__new__` 首参为 `cls` + +Initializer 模式下生成的 `__new__` 其首参 MUST 为 `cls`(Python 构造器约定),而非 `self`。Rust `#[new]` 方法的 receiver(若以 typed receiver 形式声明)MUST NOT 出现在 stub 参数中,且 MUST NOT 覆盖 `cls` 首参位置。 + +#### Scenario: `__new__` 首参注入 `cls` +- **WHEN** Initializer 模式下 `#[new]` 方法签名为 `fn new(args: &Bound<'_, PyTuple>) -> PyResult` +- **THEN** 生成的 `__new__` 首参 MUST 为 `cls`,`args` 之外的注入型参数按既有 receiver/注入规则处理 + +### Requirement: 仅识别字面量方法名 `__init__` + +Initializer 模式的触发检测 MUST 仅匹配 Rust 方法名字面量为 `__init__`(`rust_ident == "__init__"`)。`#[pyo3(name = "__init__")]` 这类 rename MUST NOT 触发 Initializer 模式分叉——与 PyO3 自身按字面量识别魔法方法的行为一致。 + +#### Scenario: `#[pyo3(name = "__init__")]` 不触发 Initializer 模式 +- **WHEN** `#[pymethods]` 声明带 `#[new]` 的方法,且存在 `#[pyo3(name = "__init__")] fn my_init(&self, ...)`(Rust 名非 `__init__`) +- **THEN** 系统 MUST NOT 进入 Initializer 模式,`#[new]` MUST 仍生成为 `def __init__` + +### Requirement: Initializer 模式检测跨全部 `#[pymethods]` 块 + +当启用 `multiple-pymethods` feature 时,字面量 `__init__` 的存在检测 MUST 跨越该类的全部 `#[pymethods]` impl 块。`#[new]` 与显式 `__init__` 出现在不同 impl 块时,分叉判定 MUST 与二者同块时一致。 + +#### Scenario: `#[new]` 与 `__init__` 分处不同 impl 块 +- **WHEN** 同一 `#[pyclass]` 在第一个 `#[pymethods]` 块声明 `#[new] fn new(...)`,在第二个 `#[pymethods]` 块声明 `fn __init__(&self, ...)` +- **THEN** 系统 MUST 进入 Initializer 模式,`#[new]` 生成为 `__new__`,第二个块的 `__init__` 生成为 `__init__` + +### Requirement: Initializer 模式下 override 精确命中、无重复 + +当用户通过 `[[override]]` 覆盖构造器时,override 项 MUST 按目标方法的**生成 stub 名**精确命中。Initializer 模式下 `::Class::__new__`(或 `#[new]` 的 rust_ident)MUST 只命中 `#[new]`,`::Class::__init__` MUST 只命中显式 `__init__`。单个 override 项 MUST NOT 同时替换 `#[new]` 与显式 `__init__` 两个方法的 stub。`#[new]` 的 dunder 别名 MUST 等于其当前生成名(非 Initializer 模式为 `__init__`,Initializer 模式为 `__new__`),MUST NOT 同时持有 `__init__` 与 `__new__` 两个别名。 + +#### Scenario: Initializer 模式下 override `__new__` 只命中 `#[new]` +- **WHEN** Initializer 模式下 override 项为 `m::MyClass::__new__` +- **THEN** 该 override MUST 只替换 `#[new]` 生成的 `__new__` stub,MUST NOT 影响显式 `__init__` 的 stub + +#### Scenario: Initializer 模式下 override `__init__` 只命中显式 `__init__` +- **WHEN** Initializer 模式下 override 项为 `m::MyClass::__init__` +- **THEN** 该 override MUST 只替换显式 `__init__` 生成的 stub,MUST NOT 同时替换 `#[new]` 的 `__new__` stub From e7f04ea962f31f09048b2e34cbe518792da7a280 Mon Sep 17 00:00:00 2001 From: monchin Date: Sun, 14 Jun 2026 13:02:12 +0800 Subject: [PATCH 3/3] chore: add PR idx in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fa22c9..f91e52a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- PyO3 [Initializer](https://pyo3.rs/main/class.html#initializer) mode support: when a class defines both `#[new]` and an explicit `fn __init__`, `#[new]` now emits `__new__(cls, ...) -> Self` and `__init__` emits `__init__(self, ...) -> None` — previously both collapsed to a single (duplicate) `__init__` definition. The `__new__` return type reuses the version-aware `Self` renderer (`t.Self` for ≥ 3.11, class name for older). `#[new]` without an explicit `__init__` is unchanged. Also aligns `[[override]]` `#[new]` aliasing with the generated stub name. +- PyO3 [Initializer](https://pyo3.rs/main/class.html#initializer) mode support: when a class defines both `#[new]` and an explicit `fn __init__`, `#[new]` now emits `__new__(cls, ...) -> Self` and `__init__` emits `__init__(self, ...) -> None` — previously both collapsed to a single (duplicate) `__init__` definition. The `__new__` return type reuses the version-aware `Self` renderer (`t.Self` for ≥ 3.11, class name for older). `#[new]` without an explicit `__init__` is unchanged. Also aligns `[[override]]` `#[new]` aliasing with the generated stub name. (#7) ### Fixed