monty 0.0.21

A sandboxed, snapshotable Python interpreter written in Rust.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
//! Implementation of Python's `dataclasses` module.
//!
//! Native, in-sandbox `@dataclass`: the decorator writes a `__dataclass_fields__`
//! mapping of [`DataclassField`]s into the class namespace and the dunders are
//! synthesized off it — no `exec`, no generated bytecode. This module owns the
//! whole feature; the generic machinery calls in through narrow hooks in
//! `bytecode::vm::call` (construction) and `types::instance` (value dunders).
//!
//! See `limitations/dataclasses.md` for divergences from CPython.

mod field;

use std::{fmt::Write, mem};

pub(crate) use self::field::DataclassField;
use crate::{
    args::{ArgValues, KwargsValues},
    bytecode::{CallResult, VM},
    defer_drop, defer_drop_mut,
    exception_private::{ExcType, ExcTypeExt, RunError, RunResult, SimpleException},
    heap::{DropGuard, DropWithContext, HeapData, HeapId, HeapRead, HeapReadOutput},
    intern::{StaticStrings, StringId},
    modules::ModuleFunctions,
    types::{
        Class, Dict, Instance, LazyHeapSet, Module,
        dataclass::write_dataclass_repr,
        instance::{class_name, instance_attr},
    },
    value::Value,
};

/// `dataclasses` module functions — each variant is a Python-visible callable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, serde::Serialize, serde::Deserialize)]
#[strum(serialize_all = "snake_case")]
pub(crate) enum DataclassesFunctions {
    /// The `@dataclass` decorator.
    Dataclass,
    /// `is_dataclass(obj)` — true for a dataclass class or instance.
    IsDataclass,
}

/// Creates the `dataclasses` module and allocates it on the heap.
pub fn create_module(vm: &mut VM<'_>) -> HeapId {
    let mut module = Module::new(StaticStrings::Dataclasses);
    module.set_attr(
        StaticStrings::Dataclass,
        Value::ModuleFunction(ModuleFunctions::Dataclasses(DataclassesFunctions::Dataclass)),
        vm,
    );
    module.set_attr(
        StaticStrings::IsDataclass,
        Value::ModuleFunction(ModuleFunctions::Dataclasses(DataclassesFunctions::IsDataclass)),
        vm,
    );
    vm.heap.allocate(HeapData::Module(Box::new(module)))
}

/// Dispatches a `dataclasses` module function call.
pub(super) fn call(vm: &mut VM<'_>, func: DataclassesFunctions, args: ArgValues) -> RunResult<Value> {
    match func {
        DataclassesFunctions::Dataclass => dataclass_decorator(vm, args),
        DataclassesFunctions::IsDataclass => is_dataclass(vm, args),
    }
}

/// The `@dataclass` decorator (bare form): writes `__dataclass_fields__` into
/// the class namespace and returns the class. `@dataclass(...)` with keyword
/// options is not yet handled.
///
/// Ownership of the single argument (the class) passes straight through to the
/// return value, so no extra refcount work is needed on the happy path.
fn dataclass_decorator(vm: &mut VM<'_>, args: ArgValues) -> RunResult<Value> {
    // `@dataclass(...)`: name the unimplemented keyword form rather than letting
    // the bare decorator's arity check report a confusing "0 given".
    if matches!(args, ArgValues::Kwargs(_) | ArgValues::ArgsKargs { .. }) {
        args.drop_with(vm);
        return Err(ExcType::not_implemented(
            "dataclass() keyword options (eq, order, frozen, unsafe_hash, ...) are not yet supported",
        )
        .into());
    }
    let cls = args.get_one_arg("dataclass", vm.heap)?;
    // The decorator returns the class it was given, so `cls` is only released
    // when something below rejects it — which the guard handles on every path.
    let mut guard = DropGuard::new(cls, vm);
    let (cls, vm) = guard.as_parts();
    // Read once and pass the handle down: everything below then works from a
    // value already known to be a class, and the guard's reference keeps it alive.
    let Value::Ref(class_id) = cls else {
        return Err(non_class_error(cls, vm));
    };
    let HeapReadOutput::Class(mut class) = vm.heap.read(*class_id) else {
        return Err(non_class_error(cls, vm));
    };
    let fields = build_dataclass_fields(&class, vm)?;
    store_dataclass_fields(&mut class, fields, vm)?;
    Ok(guard.into_inner())
}

/// `dataclass()` applied to something that is not a class.
fn non_class_error(cls: &Value, vm: &VM<'_>) -> RunError {
    let type_name = cls.py_type_name(vm);
    SimpleException::new_msg(
        ExcType::TypeError,
        format!("dataclass() should be called on a class, not '{type_name}'"),
    )
    .into()
}

/// Builds the `name -> Field` mapping for `class` from its `__annotations__`:
/// the annotated names in definition order, minus `ClassVar` entries, each
/// owning the default the class namespace bound for it (`y: int = 5`).
///
/// Also where a class body Monty cannot yet honour is rejected, so an
/// unimplemented feature raises rather than producing a subtly wrong class
/// (see `limitations/dataclasses.md`). The fields are guarded throughout, so a
/// rejection releases the defaults already captured.
fn build_dataclass_fields<'h>(class: &HeapRead<'h, Class>, vm: &mut VM<'h>) -> RunResult<Value> {
    let fields = collect_annotated_fields(class, vm);
    let mut guard = DropGuard::new(fields, vm);
    let (fields, vm) = guard.as_parts();
    // Field validation runs first, so a class CPython would reject reports
    // CPython's own message and the Monty-only guards can only fire after.
    validate_fields(vm, fields)?;
    reject_unsupported_members(class, vm)?;
    let (fields, vm) = guard.into_parts();
    allocate_fields_dict(vm, fields)
}

/// Allocates the dict `__dataclass_fields__` holds, consuming the collected
/// fields. The dict owns every `Field` from its first insertion, so a failure
/// part-way releases them with it.
fn allocate_fields_dict(vm: &mut VM<'_>, fields: Vec<DataclassField>) -> RunResult<Value> {
    let dict_id = vm.heap.allocate(HeapData::Dict(Dict::with_capacity(fields.len())));
    let mut guard = DropGuard::new(Value::Ref(dict_id), vm);
    let vm = guard.ctx();
    for field in fields {
        let name = field.name();
        let field_id = vm.heap.allocate(HeapData::DataclassField(field));
        let HeapReadOutput::Dict(mut dict) = vm.heap.read(dict_id) else {
            unreachable!("the dict was just allocated")
        };
        // Annotation keys are unique, so nothing is ever replaced — released
        // rather than asserted away so a future duplicate cannot leak. A dict
        // rejected by the memory limit releases the field it was handed.
        let replaced = dict.set(Value::InternString(name), Value::Ref(field_id), vm)?;
        replaced.drop_with(vm);
    }
    Ok(guard.into_inner())
}

/// Writes `__dataclass_fields__` into the class namespace, taking ownership of
/// `fields`. Re-decorating replaces the previous mapping, which nothing else
/// owns once it is out of the namespace.
fn store_dataclass_fields<'h>(class: &mut HeapRead<'h, Class>, fields: Value, vm: &mut VM<'h>) -> RunResult<()> {
    let replaced = class.set_attr(StaticStrings::DataclassFields.into(), fields, vm)?;
    replaced.drop_with(vm);
    Ok(())
}

/// Class-body members Monty does not dispatch, paired with what ignoring them
/// would cost. Refused rather than quietly built wrong; delete an entry once
/// its dispatch lands.
const UNSUPPORTED_MEMBERS: [(&str, &str); 1] = [("__post_init__", "which would be silently skipped")];

/// Rejects a class body defining any of [`UNSUPPORTED_MEMBERS`].
fn reject_unsupported_members<'h>(class: &HeapRead<'h, Class>, vm: &VM<'h>) -> RunResult<()> {
    let namespace = class.get(vm.heap).namespace();
    match UNSUPPORTED_MEMBERS
        .iter()
        .find(|&&(name, _)| namespace.get_by_str(name, vm.heap, vm.interns).is_some())
    {
        Some((name, consequence)) => Err(ExcType::not_implemented(format!(
            "dataclass() does not yet support {name} in a class body, {consequence}"
        ))
        .into()),
        None => Ok(()),
    }
}

/// Collects the dataclass fields from `__annotations__`, in definition order,
/// dropping `ClassVar` entries.
///
/// Each field owns its annotation and captured default from here on, so a
/// caller that discards the result must release them (`drop_with`).
fn collect_annotated_fields<'h>(class: &HeapRead<'h, Class>, vm: &VM<'h>) -> Vec<DataclassField> {
    let namespace = class.get(vm.heap).namespace();
    let ann_id = match namespace.get_by_str("__annotations__", vm.heap, vm.interns) {
        Some(Value::Ref(id)) => *id,
        _ => return Vec::new(),
    };
    let HeapData::Dict(annotations) = vm.heap.get(ann_id) else {
        return Vec::new();
    };
    let mut fields = Vec::new();
    for (key, annotation) in annotations {
        // Annotation keys are always interned field-name strings.
        let Value::InternString(name_id) = key else { continue };
        // `ClassVar[...]` entries are class variables, not dataclass fields.
        if is_classvar(&annotation_text(annotation, vm)) {
            continue;
        }
        // Captured now, as CPython bakes it into the generated `__init__`:
        // rebinding the class attribute later must not change it.
        let default = namespace
            .get_by_str(vm.interns.get_str(*name_id), vm.heap, vm.interns)
            .map(|v| v.clone_with_heap(vm.heap));
        fields.push(DataclassField::new(
            *name_id,
            annotation.clone_with_heap(vm.heap),
            default,
        ));
    }
    fields
}

/// A stringized annotation as text, or `""` for the non-string annotations
/// Monty never produces (it always stores annotations as source text).
fn annotation_text(annotation: &Value, vm: &VM<'_>) -> String {
    annotation
        .as_either_str(vm.heap)
        .map(|s| s.as_str(vm.interns).to_owned())
        .unwrap_or_default()
}

/// Rejects field lists Monty cannot honour, in CPython's checking order.
///
/// CPython validates defaults per field as it collects them, so a mutable
/// default is reported before the whole-list non-default-after-default check.
fn validate_fields(vm: &mut VM<'_>, fields: &[DataclassField]) -> RunResult<()> {
    for field in fields {
        let name = vm.interns.get_str(field.name()).to_owned();
        if is_initvar(&annotation_text(field.annotation(), vm)) {
            return Err(ExcType::not_implemented(format!(
                "dataclass() does not yet support InitVar (field {name}), which would become an ordinary field"
            ))
            .into());
        }
        if let Some(default) = field.default() {
            // CPython's rule is hashability, not a list/dict/set type check, so
            // a class with `__hash__ = None` is rejected the same way. Borrowed,
            // not consumed — `build_dataclass_fields` releases it on rejection.
            if default.py_hash(vm)?.is_none() {
                let ty = default.py_type_name(vm);
                return Err(ExcType::value_error(format!(
                    "mutable default <class '{ty}'> for field {name} is not allowed: use default_factory"
                )));
            }
        }
    }
    // CPython rejects the class outright rather than failing at construction.
    if let Some((prev, field)) = first_non_default_after_default(fields) {
        let (prev, field) = (vm.interns.get_str(prev), vm.interns.get_str(field));
        return Err(ExcType::type_error(format!(
            "non-default argument '{field}' follows default argument '{prev}'"
        )));
    }
    Ok(())
}

/// The first `(defaulted, non_defaulted)` field pair that would produce an
/// invalid `__init__` signature, or `None` when the order is valid.
fn first_non_default_after_default(fields: &[DataclassField]) -> Option<(StringId, StringId)> {
    let mut last_default = None;
    fields
        .iter()
        .find_map(|field| match (field.default().is_some(), last_default) {
            (true, _) => {
                last_default = Some(field.name());
                None
            }
            (false, Some(prev)) => Some((prev, field.name())),
            (false, None) => None,
        })
}

/// Whether a stringized annotation denotes `typing.ClassVar`, so the field is
/// excluded.
///
/// Detection is textual where CPython resolves the annotation through the
/// defining module, so neither the import nor a dotted spelling's module is
/// checked — both divergences are in `limitations/dataclasses.md`.
fn is_classvar(annotation: &str) -> bool {
    annotation_head(annotation, "ClassVar")
}

/// Whether a stringized annotation denotes `dataclasses.InitVar`. Matched the
/// same way as [`is_classvar`], and with the same textual-only caveats.
fn is_initvar(annotation: &str) -> bool {
    annotation_head(annotation, "InitVar")
}

/// Whether a stringized annotation names `name`, bare, subscripted, or
/// dotted (`typing.ClassVar[int]`), ignoring surrounding whitespace and quotes.
///
/// Quotes must be stripped because a quoted annotation (`x: "ClassVar[int]"`)
/// is stringized with its quotes intact. The subscript is split off before the
/// qualifier check so a dotted type argument (`ClassVar[a.b]`) still matches.
fn annotation_head(annotation: &str, name: &str) -> bool {
    let s = annotation.trim().trim_matches(|c| c == '\'' || c == '"').trim();
    let head = s.split_once('[').map_or(s, |(head, _)| head).trim_end();
    head.rsplit('.').next() == Some(name)
}

/// The `__dataclass_fields__` dict a class namespace holds, or `None` when the
/// class is not a dataclass.
///
/// Every reader goes through the namespace, so a class whose
/// `__dataclass_fields__` sandboxed code overwrote simply stops being a
/// dataclass rather than reading metadata the class no longer advertises.
fn fields_dict_id(namespace: &Dict, vm: &VM<'_>) -> Option<HeapId> {
    match namespace.get_by_str(StaticStrings::DataclassFields.into(), vm.heap, vm.interns) {
        Some(Value::Ref(id)) if matches!(vm.heap.get(*id), HeapData::Dict(_)) => Some(*id),
        _ => None,
    }
}

/// [`fields_dict_id`] for the callers holding only a class `HeapId` — the
/// generic `Instance` code, which reaches a class through its instance.
fn class_fields_dict_id(class_id: HeapId, vm: &VM<'_>) -> Option<HeapId> {
    match vm.heap.get(class_id) {
        HeapData::Class(class) => fields_dict_id(class.namespace(), vm),
        _ => None,
    }
}

/// Dataclass field names in definition order, or `None` for a plain class —
/// the `Some`/`None` split the generic `Instance` code branches on.
pub(crate) fn dataclass_fields(class_id: HeapId, vm: &VM<'_>) -> Option<Vec<StringId>> {
    let fields_id = class_fields_dict_id(class_id, vm)?;
    Some(field_specs(fields_id, vm).into_iter().map(|(name, _)| name).collect())
}

/// Each field's `(name, has_default)`, in definition order — everything
/// `bind_dataclass_fields` needs once the heap borrow is released.
fn field_specs(fields_id: HeapId, vm: &VM<'_>) -> Vec<(StringId, bool)> {
    let HeapData::Dict(fields) = vm.heap.get(fields_id) else {
        return Vec::new();
    };
    fields
        .iter()
        .filter_map(|(_, value)| match value {
            Value::Ref(id) => match vm.heap.get(*id) {
                HeapData::DataclassField(field) => Some((field.name(), field.default().is_some())),
                _ => None,
            },
            _ => None,
        })
        .collect()
}

/// Whether `class_id` names a dataclass, when the field names aren't needed.
pub(crate) fn is_dataclass_class(class_id: HeapId, vm: &VM<'_>) -> bool {
    class_fields_dict_id(class_id, vm).is_some()
}

/// Constructs a dataclass instance by binding `args` to the recorded fields,
/// taking ownership of the freshly allocated `instance`.
///
/// No bytecode is generated: arguments bind natively as CPython's synthesized
/// `__init__(self, f1, f2=default, ...)` would, filling unbound fields from the
/// defaults captured at decoration time. Drops the instance on error.
pub(crate) fn dataclass_init<'h>(
    vm: &mut VM<'h>,
    class: &HeapRead<'h, Class>,
    instance: Value,
    args: ArgValues,
) -> Result<CallResult, RunError> {
    let fields_id =
        fields_dict_id(class.get(vm.heap).namespace(), vm).expect("dataclass_init requires __dataclass_fields__");
    // Owned field metadata (name + has-default), releasing the heap borrow.
    let fields = field_specs(fields_id, vm);

    // The instance is owned from here: the guard releases it if binding or
    // storing fails, and hands it back to the caller when they succeed.
    let mut guard = DropGuard::new(instance, vm);
    let (instance, vm) = guard.as_parts();
    // Read once for the whole construction. The handle is declared after the
    // guard so it is released first — a guard that frees the instance must not
    // find a live reader.
    let Value::Ref(instance_id) = instance else {
        unreachable!("the caller just allocated the instance")
    };
    let HeapReadOutput::Instance(mut instance) = vm.heap.read(*instance_id) else {
        unreachable!("the caller just allocated the instance")
    };
    let values = bind_dataclass_fields(vm, class, fields_id, &fields, args)?;
    store_bound_fields(&mut instance, &fields, values, vm)?;
    Ok(CallResult::Value(guard.into_inner()))
}

/// Stores one bound value per field on the instance `__dict__`, consuming
/// `values`. A set failure (a resource limit) leaves the untouched tail to the
/// guard; the caller owns the instance.
fn store_bound_fields<'h>(
    instance: &mut HeapRead<'h, Instance>,
    fields: &[(StringId, bool)],
    values: Vec<Value>,
    vm: &mut VM<'h>,
) -> Result<(), RunError> {
    defer_drop_mut!(values, vm);
    for i in 0..values.len() {
        let value = mem::replace(&mut values[i], Value::None);
        let name = Value::InternString(fields[i].0);
        let replaced = instance.set_attr(name, value, vm)?;
        replaced.drop_with(vm);
    }
    Ok(())
}

/// Binds `args` to one value per dataclass field, in field order.
///
/// Bound arguments are guarded, so an error on any path releases them and the
/// unbound ones alike. The instance is untouched; the caller owns it.
///
/// Every key is a string here: `**` unpacking is the only way a non-string one
/// could arrive, and `DictMerge` raises `keywords must be strings` while
/// building the call's kwargs — before any binder runs, as CPython does.
///
/// TODO(dataclass-keywords): a third binder alongside `args::bind_native` and
/// `args::bind_python`, all three reproducing CPython's error wording and able
/// to drift. Fold into `Signature::bind` when the `@dataclass(...)` keyword form
/// lands — not a drop-in, as that fills a `&mut Vec<Value>` from `&[Value]`
/// defaults where this needs owned clones of the captured ones.
fn bind_dataclass_fields<'h>(
    vm: &mut VM<'h>,
    class: &HeapRead<'h, Class>,
    fields_id: HeapId,
    fields: &[(StringId, bool)],
    args: ArgValues,
) -> Result<Vec<Value>, RunError> {
    let n_fields = fields.len();
    let init_name = format!("{}.__init__", class.get(vm.heap).name().as_str(vm.interns));
    let (pos_iter, kwargs) = args.into_parts();

    // Every bound argument lands in `values`, guarded so each error below is a
    // plain `?` — nothing else holds an owned reference across them.
    let values: Vec<Option<Value>> = (0..n_fields).map(|_| None).collect();
    let mut guard = DropGuard::new(values, vm);
    let (values, vm) = guard.as_parts_mut();

    // Positionals fill the leftmost slots; one beyond the last field is dropped
    // here and only counted, since CPython binds keywords first: `P(1, 2, 3,
    // y=4)` is "multiple values for argument 'y'", not an arity error.
    let mut n_pos = 0;
    for (slot, value) in pos_iter.enumerate() {
        n_pos += 1;
        match values.get_mut(slot) {
            Some(unbound) => *unbound = Some(value),
            None => value.drop_with(vm),
        }
    }

    bind_keyword_args(values, fields, kwargs, &init_name, vm)?;

    // Too many positionals (CPython counts `self`, so +1 each side), reported
    // only once keyword binding has had its say.
    if n_pos > n_fields {
        let required = fields.iter().filter(|(_, has_default)| !has_default).count();
        return Err(ExcType::type_error_too_many_positional_range(
            &init_name,
            1 + required,
            1 + n_fields,
            1 + n_pos,
            0,
        ));
    }

    // Unbound slots take the default captured at decoration time; a field with
    // none is missing from the call.
    let mut missing: Vec<String> = Vec::new();
    for (idx, (id, _)) in fields.iter().enumerate() {
        if values[idx].is_some() {
            continue;
        }
        match captured_default(vm, fields_id, idx) {
            Some(default) => values[idx] = Some(default),
            None => missing.push(vm.interns.get_str(*id).to_owned()),
        }
    }
    if !missing.is_empty() {
        let refs: Vec<&str> = missing.iter().map(String::as_str).collect();
        return Err(ExcType::type_error_missing_positional_with_names(&init_name, &refs));
    }

    Ok(guard
        .into_inner()
        .into_iter()
        .map(|v| v.expect("all fields bound"))
        .collect())
}

/// Fills the named slots of `values` from the keyword arguments.
///
/// The iterator is guarded, so an error mid-mapping raises on the spot and the
/// pairs it never reached are still released. Keys are known to be strings —
/// the caller rejects a non-string one before any binding happens.
fn bind_keyword_args(
    values: &mut [Option<Value>],
    fields: &[(StringId, bool)],
    kwargs: KwargsValues,
    init_name: &str,
    vm: &mut VM<'_>,
) -> RunResult<()> {
    let kwargs = kwargs.into_iter();
    defer_drop_mut!(kwargs, vm);
    for (key, value) in kwargs.by_ref() {
        defer_drop!(key, vm);
        let name = key
            .as_either_str(vm.heap)
            .expect("DictMerge rejects non-string keys before the call")
            .as_str(vm.interns)
            .to_owned();
        // Resolved before the value is guarded, which borrows the VM.
        let slot = fields.iter().position(|(id, _)| vm.interns.get_str(*id) == name);
        let value = DropGuard::new(value, vm);
        match slot {
            Some(idx) if values[idx].is_none() => values[idx] = Some(value.into_inner()),
            Some(_) => return Err(ExcType::type_error_duplicate_arg(init_name, &name)),
            None => return Err(ExcType::type_error_unexpected_keyword(init_name, &name)),
        }
    }
    Ok(())
}

/// A fresh reference to the default `@dataclass` captured for field `idx`, or
/// `None` for a required field.
fn captured_default(vm: &VM<'_>, fields_id: HeapId, idx: usize) -> Option<Value> {
    let HeapData::Dict(fields) = vm.heap.get(fields_id) else {
        return None;
    };
    match fields.value_at(idx) {
        Some(Value::Ref(id)) => match vm.heap.get(*id) {
            HeapData::DataclassField(field) => field.default().map(|v| v.clone_with_heap(vm.heap)),
            _ => None,
        },
        _ => None,
    }
}

/// Field-wise `__eq__`: equal only to the *same* dataclass with equal fields.
/// Any other operand is `NotImplemented` (`Ok(None)`), leaving the caller on
/// identity.
///
/// Mirrors CPython 3.14's generated `self.a == other.a and self.b == other.b` —
/// left to right, stopping at the first unequal pair, each compared with the
/// `==` *operator* rather than container equality.
pub(crate) fn dataclass_eq(
    self_id: HeapId,
    field_names: &[StringId],
    other: &Value,
    vm: &mut VM<'_>,
) -> RunResult<Option<bool>> {
    let class_id = instance_class(self_id, vm);
    // Only the same dataclass type can be equal; otherwise NotImplemented.
    let &Value::Ref(other_id) = other else {
        return Ok(None);
    };
    if !matches!(vm.heap.get(other_id), HeapData::Instance(inst) if inst.class() == class_id) {
        return Ok(None);
    }
    // Charge a recursion level: two distinct cyclic dataclasses (`a.x = a;
    // b.x = b; a == b`) re-enter here per level and would otherwise overflow
    // the host stack.
    let mut guard = vm.recursion_guard()?;
    let vm = &mut *guard;
    for name_id in field_names {
        let field_name = vm.interns.get_str(*name_id).to_owned();
        // Both reads are guarded so a failing comparison below (or an early
        // return) cannot strand either value.
        let a = instance_attr(self_id, &field_name, vm);
        defer_drop!(a, vm);
        let b = instance_attr(other_id, &field_name, vm);
        defer_drop!(b, vm);
        match (a, b) {
            (Some(a), Some(b)) if !a.py_eq_operator(b, vm)? => return Ok(Some(false)),
            (Some(_), Some(_)) => {}
            // The generated `__eq__` reads each field as an attribute, so one
            // that resolves nowhere raises rather than comparing unequal.
            _ => {
                let class_name = class_name(class_id, vm.heap, vm.interns).into_owned();
                return Err(ExcType::attribute_error(&class_name, &field_name));
            }
        }
    }
    Ok(Some(true))
}

/// Synthesized `__repr__`: `ClassName(f1=v1, ...)`. Formatting is shared with
/// the host-supplied `Dataclass` via [`write_dataclass_repr`] so the two cannot
/// drift.
pub(crate) fn dataclass_repr_fmt(
    self_id: HeapId,
    field_names: &[StringId],
    f: &mut impl Write,
    vm: &mut VM<'_>,
    heap_ids: &mut LazyHeapSet,
) -> RunResult<()> {
    let class_id = instance_class(self_id, vm);
    let name = class_name(class_id, vm.heap, vm.interns).to_string();
    // Each field is read as an attribute at the point it is written, so a field
    // whose `__repr__` mutates a later one is observed, exactly as CPython's
    // generated f-string does. A field that resolves nowhere raises.
    write_dataclass_repr(f, &name, field_names.len(), vm, heap_ids, |i, vm| {
        let field_name = vm.interns.get_str(field_names[i]).to_owned();
        match instance_attr(self_id, &field_name, vm) {
            Some(value) => Ok((field_name, Some(value))),
            None => Err(ExcType::attribute_error(&name, &field_name)),
        }
    })
}

/// Returns the `HeapId` of a dataclass instance's class object.
///
/// # Panics
/// If `self_id` is not an `Instance` — being one is how the caller chose this
/// dispatch in the first place.
fn instance_class(self_id: HeapId, vm: &VM<'_>) -> HeapId {
    match vm.heap.get(self_id) {
        HeapData::Instance(inst) => inst.class(),
        _ => unreachable!("dataclass dispatch on a non-instance"),
    }
}

/// `is_dataclass(obj)` — true when `obj` is a dataclass **class** or an
/// **instance** of one (matching CPython, which accepts both).
fn is_dataclass(vm: &mut VM<'_>, args: ArgValues) -> RunResult<Value> {
    let arg = args.get_one_arg("is_dataclass", vm.heap)?;
    let result = match &arg {
        Value::Ref(id) => match vm.heap.get(*id) {
            HeapData::Class(_) => is_dataclass_class(*id, vm),
            HeapData::Instance(instance) => is_dataclass_class(instance.class(), vm),
            _ => false,
        },
        _ => false,
    };
    arg.drop_with(vm);
    Ok(Value::Bool(result))
}