This is an automated email from the ASF dual-hosted git repository.

junrushao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm-ffi.git


The following commit(s) were added to refs/heads/main by this push:
     new c73d61a  feat: add `__copy__`, `__deepcopy__`, and `__replace__` for 
FFI objects (#438)
c73d61a is described below

commit c73d61a423edf69483676f727cf272feebbe4d49
Author: Junru Shao <[email protected]>
AuthorDate: Fri Feb 13 19:01:46 2026 -0800

    feat: add `__copy__`, `__deepcopy__`, and `__replace__` for FFI objects 
(#438)
    
    ## Summary
    This PR adds first-class copy support to TVM-FFI objects, including
    Python `copy.copy`, `copy.deepcopy`, and dataclass-style `__replace__`.
    
    ## Changes
    - Adds reflection-based deep-copy runtime support in C++:
      - new `ffi.DeepCopy` entrypoint (`src/ffi/extra/deep_copy.cc`), which
        does memoized graph copy that preserves shared references and
        cycles.
        - recursive copy of `ffi.Array` / `ffi.Map`
        - treats `Str`/`Bytes` as immutable terminal values
    - resolves fields by runtime value so `Any`/`ObjectRef` fields
    containing containers/objects are copied correctly
    - Auto-registers shallow-copy support for copy-constructible reflected
    object types:
        - introduces `__ffi_shallow_copy__` registration in `ObjectDef`
        - exposes shallow-copy method via type attributes for generic lookup
    - Wires Python class registration to install:
        - `__copy__`, `__deepcopy__`, and `__replace__` for supported types
        - clear `TypeError` for non-copyable types
    - deepcopy support for container roots (`ffi.Array`, `ffi.Map`) via
    `ffi.DeepCopy`
    - Updates dataclass/c_class method attachment to use reflected callables
    consistently and hook copy setup.
    - Adds extensive test coverage in `tests/python/test_copy.py`,
    including:
        - shallow/deep copy behavior
        - shared-reference and cycle preservation
        - long-string deep copy edge cases
        - `Any` and `ObjectRef` deep copy edge cases
        - non-copyable type failures (`TestNonCopyable`)
---
 CMakeLists.txt                        |   1 +
 include/tvm/ffi/extra/deep_copy.h     |  48 +++
 include/tvm/ffi/reflection/registry.h |  28 +-
 python/tvm_ffi/dataclasses/_utils.py  |  31 +-
 python/tvm_ffi/dataclasses/c_class.py |   5 +
 python/tvm_ffi/registry.py            |  78 +++-
 python/tvm_ffi/testing/__init__.py    |   1 +
 python/tvm_ffi/testing/testing.py     |   7 +
 src/ffi/extra/deep_copy.cc            | 163 ++++++++
 src/ffi/testing/testing.cc            |  32 +-
 tests/python/test_copy.py             | 690 ++++++++++++++++++++++++++++++++++
 11 files changed, 1067 insertions(+), 17 deletions(-)

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 21348d4..16c41de 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -77,6 +77,7 @@ set(_tvm_ffi_extra_objs_sources
     "${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/extra/json_parser.cc"
     "${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/extra/json_writer.cc"
     "${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/extra/serialization.cc"
+    "${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/extra/deep_copy.cc"
     "${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/extra/reflection_extra.cc"
     "${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/extra/module.cc"
     "${CMAKE_CURRENT_SOURCE_DIR}/src/ffi/extra/library_module.cc"
diff --git a/include/tvm/ffi/extra/deep_copy.h 
b/include/tvm/ffi/extra/deep_copy.h
new file mode 100644
index 0000000..3e3a9db
--- /dev/null
+++ b/include/tvm/ffi/extra/deep_copy.h
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+/*!
+ * \file tvm/ffi/extra/deep_copy.h
+ * \brief Reflection-based object copy utilities
+ */
+#ifndef TVM_FFI_EXTRA_DEEP_COPY_H_
+#define TVM_FFI_EXTRA_DEEP_COPY_H_
+
+#include <tvm/ffi/any.h>
+#include <tvm/ffi/extra/base.h>
+
+namespace tvm {
+namespace ffi {
+
+/**
+ * \brief Deep copy an ffi::Any value.
+ *
+ * Recursively copies the value and all reachable objects in its object graph.
+ * Copy-constructible types with `ObjectDef` registration automatically 
support deep copy.
+ * Primitive types, strings, and bytes are returned as-is (they are immutable).
+ * Arrays, Lists, and Maps are recursively deep copied.
+ * Objects without copy support cause a runtime error.
+ *
+ * \param value The value to deep copy.
+ * \return The deep copied value.
+ */
+TVM_FFI_EXTRA_CXX_API Any DeepCopy(const Any& value);
+
+}  // namespace ffi
+}  // namespace tvm
+#endif  // TVM_FFI_EXTRA_DEEP_COPY_H_
diff --git a/include/tvm/ffi/reflection/registry.h 
b/include/tvm/ffi/reflection/registry.h
index 1dc22ae..cc4ec50 100644
--- a/include/tvm/ffi/reflection/registry.h
+++ b/include/tvm/ffi/reflection/registry.h
@@ -460,6 +460,12 @@ struct init {
   }
 };
 
+/*! \brief Well-known type attribute names used by the reflection system. */
+namespace type_attr {
+inline constexpr const char* kInit = "__ffi_init__";
+inline constexpr const char* kShallowCopy = "__ffi_shallow_copy__";
+}  // namespace type_attr
+
 /*!
  * \brief Helper to register Object's reflection metadata.
  * \tparam Class The class type.
@@ -481,6 +487,7 @@ class ObjectDef : public ReflectionDefBase {
   explicit ObjectDef(ExtraArgs&&... extra_args)
       : type_index_(Class::_GetOrAllocRuntimeTypeIndex()), 
type_key_(Class::_type_key) {
     RegisterExtraInfo(std::forward<ExtraArgs>(extra_args)...);
+    AutoRegisterCopy();
   }
 
   /*!
@@ -591,6 +598,25 @@ class ObjectDef : public ReflectionDefBase {
   template <typename T>
   friend class OverloadObjectDef;
 
+  /*! \brief Shallow-copy \p self via the C++ copy constructor. */
+  static ObjectRef ShallowCopy(const Class* self) {
+    return ObjectRef(ffi::make_object<Class>(*self));
+  }
+
+  void AutoRegisterCopy() {
+    if constexpr (std::is_copy_constructible_v<Class>) {
+      // Register __ffi_shallow_copy__ as an instance method
+      RegisterMethod(type_attr::kShallowCopy, false, &ObjectDef::ShallowCopy);
+      // Also register as a type attribute for generic deep copy lookup
+      Function copy_fn = GetMethod(std::string(type_key_) + "." + 
type_attr::kShallowCopy,
+                                   &ObjectDef::ShallowCopy);
+      TVMFFIByteArray attr_name = {type_attr::kShallowCopy,
+                                   
std::char_traits<char>::length(type_attr::kShallowCopy)};
+      TVMFFIAny attr_value = AnyView(copy_fn).CopyToTVMFFIAny();
+      TVM_FFI_CHECK_SAFE_CALL(TVMFFITypeRegisterAttr(type_index_, &attr_name, 
&attr_value));
+    }
+  }
+
   template <typename... ExtraArgs>
   void RegisterExtraInfo(ExtraArgs&&... extra_args) {
     TVMFFITypeMetadata info;
@@ -663,7 +689,7 @@ class ObjectDef : public ReflectionDefBase {
 
   int32_t type_index_;
   const char* type_key_;
-  static constexpr const char* kInitMethodName = "__ffi_init__";
+  static constexpr const char* kInitMethodName = type_attr::kInit;
 };
 
 /*!
diff --git a/python/tvm_ffi/dataclasses/_utils.py 
b/python/tvm_ffi/dataclasses/_utils.py
index 80e7010..812a419 100644
--- a/python/tvm_ffi/dataclasses/_utils.py
+++ b/python/tvm_ffi/dataclasses/_utils.py
@@ -55,22 +55,27 @@ def type_info_to_cls(
         attrs[field.name] = field.as_property(cls)
 
     # Step 3. Add methods
-    def _add_method(name: str, func: Callable[..., Any]) -> None:
-        if name == "__ffi_init__":
-            name = "__c_ffi_init__"
-        # Allow overriding methods (including from base classes like 
Object.__repr__)
-        # by always adding to attrs, which will be used when creating the new 
class
-        func.__module__ = cls.__module__
-        func.__name__ = name  # ty: ignore[unresolved-attribute]
-        func.__qualname__ = f"{cls.__qualname__}.{name}"  # ty: 
ignore[unresolved-attribute]
-        func.__doc__ = f"Method `{name}` of class `{cls.__qualname__}`"
-        attrs[name] = func
-
     for name, method_impl in methods.items():
         if method_impl is not None:
-            _add_method(name, method_impl)
+            method_impl.__module__ = cls.__module__
+            method_impl.__name__ = name  # ty: ignore[unresolved-attribute]
+            method_impl.__qualname__ = f"{cls.__qualname__}.{name}"  # ty: 
ignore[unresolved-attribute]
+            method_impl.__doc__ = f"Method `{name}` of class 
`{cls.__qualname__}`"
+            attrs[name] = method_impl
     for method in type_info.methods:
-        _add_method(method.name, method.func)
+        name = method.name
+        if name == "__ffi_init__":
+            name = "__c_ffi_init__"
+        # as_callable wraps instance methods so `self` is passed to the C++ 
function,
+        # and wraps static methods with staticmethod(); it also sets 
__module__,
+        # __name__, __qualname__, and __doc__ so we insert directly into attrs.
+        func = method.as_callable(cls)
+        if name != method.name:
+            # Rename was applied (e.g. __ffi_init__ -> __c_ffi_init__)
+            inner = func.__func__ if isinstance(func, staticmethod) else func
+            inner.__name__ = name  # ty: ignore[invalid-assignment]
+            inner.__qualname__ = f"{cls.__qualname__}.{name}"  # ty: 
ignore[invalid-assignment]
+        attrs[name] = func
 
     # Step 4. Create the new class
     new_cls = type(cls.__name__, cls_bases, attrs)
diff --git a/python/tvm_ffi/dataclasses/c_class.py 
b/python/tvm_ffi/dataclasses/c_class.py
index 8dd5e5a..60e3ad4 100644
--- a/python/tvm_ffi/dataclasses/c_class.py
+++ b/python/tvm_ffi/dataclasses/c_class.py
@@ -153,6 +153,11 @@ def c_class(
             methods={"__init__": fn_init, "__repr__": fn_repr},
         )
         _set_type_cls(type_info, type_cls)
+        # Step 4. Set up __copy__, __deepcopy__, __replace__
+        from ..registry import _setup_copy_methods  # noqa: PLC0415
+
+        has_shallow_copy = any(m.name == "__ffi_shallow_copy__" for m in 
type_info.methods)
+        _setup_copy_methods(type_cls, has_shallow_copy)
         return type_cls
 
     return decorator
diff --git a/python/tvm_ffi/registry.py b/python/tvm_ffi/registry.py
index 8126a66..049ace0 100644
--- a/python/tvm_ffi/registry.py
+++ b/python/tvm_ffi/registry.py
@@ -18,6 +18,7 @@
 
 from __future__ import annotations
 
+import functools
 import json
 import sys
 from typing import Any, Callable, Literal, Sequence, TypeVar, overload
@@ -335,25 +336,100 @@ def _add_class_attrs(type_cls: type, type_info: 
TypeInfo) -> type:
         if not hasattr(type_cls, name):  # skip already defined attributes
             setattr(type_cls, name, field.as_property(type_cls))
     has_c_init = False
+    has_shallow_copy = False
     for method in type_info.methods:
         name = method.name
         if name == "__ffi_init__":
             name = "__c_ffi_init__"
             has_c_init = True
-        if not hasattr(type_cls, name):
+        if name == "__ffi_shallow_copy__":
+            has_shallow_copy = True
+            # Always override: shallow copy is type-specific and must not be 
inherited
+            setattr(type_cls, name, method.as_callable(type_cls))
+        elif not hasattr(type_cls, name):
             setattr(type_cls, name, method.as_callable(type_cls))
     if "__init__" not in type_cls.__dict__:
         if has_c_init:
             setattr(type_cls, "__init__", getattr(type_cls, "__ffi_init__"))
         elif not issubclass(type_cls, core.PyNativeObject):
             setattr(type_cls, "__init__", __init__invalid)
+    is_container = type_info.type_key in ("ffi.Array", "ffi.Map")
+    _setup_copy_methods(type_cls, has_shallow_copy, is_container=is_container)
     return type_cls
 
 
+def _setup_copy_methods(
+    type_cls: type, has_shallow_copy: bool, *, is_container: bool = False
+) -> None:
+    """Set up __copy__, __deepcopy__, __replace__ based on copy support."""
+    if has_shallow_copy:
+        if "__copy__" not in type_cls.__dict__:
+            setattr(type_cls, "__copy__", _copy_supported)
+        if "__deepcopy__" not in type_cls.__dict__:
+            setattr(type_cls, "__deepcopy__", _deepcopy_supported)
+        if "__replace__" not in type_cls.__dict__:
+            setattr(type_cls, "__replace__", _replace_supported)
+    else:
+        if "__copy__" not in type_cls.__dict__:
+            setattr(type_cls, "__copy__", _copy_unsupported)
+        if "__deepcopy__" not in type_cls.__dict__:
+            # Containers (Array, Map) support deepcopy via ffi.DeepCopy
+            # even without __ffi_shallow_copy__
+            if is_container:
+                setattr(type_cls, "__deepcopy__", _deepcopy_supported)
+            else:
+                setattr(type_cls, "__deepcopy__", _deepcopy_unsupported)
+        if "__replace__" not in type_cls.__dict__:
+            setattr(type_cls, "__replace__", _replace_unsupported)
+
+
 def __init__invalid(self: Any, *args: Any, **kwargs: Any) -> None:
     raise RuntimeError("The __init__ method of this class is not implemented.")
 
 
+def _copy_supported(self: Any) -> Any:
+    return self.__ffi_shallow_copy__()
+
+
+def _deepcopy_supported(self: Any, memo: Any = None) -> Any:
+    return _get_deep_copy_func()(self)
+
+
[email protected]_cache(maxsize=1)
+def _get_deep_copy_func() -> core.Function:
+    return get_global_func("ffi.DeepCopy")
+
+
+def _replace_supported(self: Any, **kwargs: Any) -> Any:
+    import copy  # noqa: PLC0415
+
+    obj = copy.copy(self)
+    for key, value in kwargs.items():
+        setattr(obj, key, value)
+    return obj
+
+
+def _copy_unsupported(self: Any) -> Any:
+    raise TypeError(
+        f"Type `{type(self).__name__}` does not support copy. "
+        f"The underlying C++ type is not copy-constructible."
+    )
+
+
+def _deepcopy_unsupported(self: Any, memo: Any = None) -> Any:
+    raise TypeError(
+        f"Type `{type(self).__name__}` does not support deepcopy. "
+        f"The underlying C++ type is not copy-constructible."
+    )
+
+
+def _replace_unsupported(self: Any, **kwargs: Any) -> Any:
+    raise TypeError(
+        f"Type `{type(self).__name__}` does not support replace. "
+        f"The underlying C++ type is not copy-constructible."
+    )
+
+
 def get_registered_type_keys() -> Sequence[str]:
     """Get the list of valid type keys registered to TVM-FFI.
 
diff --git a/python/tvm_ffi/testing/__init__.py 
b/python/tvm_ffi/testing/__init__.py
index 32520cc..b54d760 100644
--- a/python/tvm_ffi/testing/__init__.py
+++ b/python/tvm_ffi/testing/__init__.py
@@ -19,6 +19,7 @@
 from ._ffi_api import *  # noqa: F403
 from .testing import (
     TestIntPair,
+    TestNonCopyable,
     TestObjectBase,
     TestObjectDerived,
     _SchemaAllTypes,
diff --git a/python/tvm_ffi/testing/testing.py 
b/python/tvm_ffi/testing/testing.py
index cb6a0e1..08cc3c3 100644
--- a/python/tvm_ffi/testing/testing.py
+++ b/python/tvm_ffi/testing/testing.py
@@ -81,6 +81,13 @@ class TestObjectDerived(TestObjectBase):
     # tvm-ffi-stubgen(end)
 
 
+@register_object("testing.TestNonCopyable")
+class TestNonCopyable(Object):
+    """Test object with deleted copy constructor."""
+
+    value: int
+
+
 @register_object("testing.SchemaAllTypes")
 class _SchemaAllTypes:
     # tvm-ffi-stubgen(ty-map): testing.SchemaAllTypes -> 
testing._SchemaAllTypes
diff --git a/src/ffi/extra/deep_copy.cc b/src/ffi/extra/deep_copy.cc
new file mode 100644
index 0000000..0b39094
--- /dev/null
+++ b/src/ffi/extra/deep_copy.cc
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+/*
+ * \file src/ffi/extra/deep_copy.cc
+ *
+ * \brief Reflection-based deep copy utilities.
+ */
+#include <tvm/ffi/any.h>
+#include <tvm/ffi/container/array.h>
+#include <tvm/ffi/container/list.h>
+#include <tvm/ffi/container/map.h>
+#include <tvm/ffi/container/shape.h>
+#include <tvm/ffi/error.h>
+#include <tvm/ffi/extra/deep_copy.h>
+#include <tvm/ffi/reflection/accessor.h>
+#include <tvm/ffi/reflection/registry.h>
+
+#include <unordered_map>
+#include <vector>
+
+namespace tvm {
+namespace ffi {
+
+/*!
+ * \brief Deep copier with memoization.
+ *
+ * - Arrays / Maps: Resolve() recurses to rebuild with resolved children.
+ * - Copyable objects: shallow-copied immediately into copy_map_ (so cyclic
+ *   back-references resolve), then queued for field resolution.
+ * - The queue is drained iteratively by Run(), bounding recursion depth
+ *   to container nesting rather than object-graph depth.
+ * - Shared references are preserved: the same original maps to the same copy.
+ */
+class ObjectDeepCopier {
+ public:
+  explicit ObjectDeepCopier(reflection::TypeAttrColumn* column) : 
column_(column) {}
+
+  Any Run(const Any& value) {
+    if (value.type_index() < TypeIndex::kTVMFFIStaticObjectBegin) return value;
+    Any result = Resolve(value);
+    // NOLINTNEXTLINE(modernize-loop-convert): queue grows during iteration
+    for (size_t i = 0; i < resolve_queue_.size(); ++i) {
+      ResolveFields(resolve_queue_[i]);
+    }
+    return result;
+  }
+
+ private:
+  /*! \brief Resolve a value: pass through primitives, copy/rebuild objects. */
+  Any Resolve(const Any& value) {
+    if (value.type_index() < TypeIndex::kTVMFFIStaticObjectBegin) {
+      return value;
+    }
+    const Object* obj = value.as<Object>();
+    if (auto it = copy_map_.find(obj); it != copy_map_.end()) {
+      return it->second;
+    }
+    int32_t ti = obj->type_index();
+    // Strings, bytes, and shapes are immutable — return as-is.
+    if (ti == TypeIndex::kTVMFFIStr || ti == TypeIndex::kTVMFFIBytes ||
+        ti == TypeIndex::kTVMFFIShape) {
+      return value;
+    }
+    if (ti == TypeIndex::kTVMFFIArray) {
+      // NOTE: The new array is registered in copy_map_ only after all elements
+      // are resolved.  This means a cyclic self-reference (array containing
+      // itself) would not preserve pointer equality.  This is acceptable
+      // because Array is immutable and such cycles cannot be constructed.
+      const ArrayObj* orig = value.as<ArrayObj>();
+      Array<Any> new_arr;
+      new_arr.reserve(static_cast<int64_t>(orig->size()));
+      for (const Any& elem : *orig) {
+        new_arr.push_back(Resolve(elem));
+      }
+      copy_map_[obj] = new_arr;
+      return new_arr;
+    }
+    if (ti == TypeIndex::kTVMFFIList) {
+      // List is mutable, so cyclic self-references are possible.
+      // Register the empty copy in copy_map_ before resolving children
+      // so that back-references resolve to the same new List.
+      const ListObj* orig = value.as<ListObj>();
+      List<Any> new_list;
+      new_list.reserve(static_cast<int64_t>(orig->size()));
+      copy_map_[obj] = new_list;
+      for (const Any& elem : *orig) {
+        new_list.push_back(Resolve(elem));
+      }
+      return new_list;
+    }
+    if (ti == TypeIndex::kTVMFFIMap) {
+      // NOTE: Same as Array above — Map is immutable, so cyclic
+      // self-references cannot occur and late registration is safe.
+      const MapObj* orig = value.as<MapObj>();
+      Map<Any, Any> new_map;
+      for (const auto& [k, v] : *orig) {
+        new_map.Set(Resolve(k), Resolve(v));
+      }
+      copy_map_[obj] = new_map;
+      return new_map;
+    }
+    // General object: shallow-copy, register, and queue for field resolution.
+    const TVMFFITypeInfo* type_info = TVMFFIGetTypeInfo(ti);
+    TVM_FFI_ICHECK((*column_)[ti] != nullptr)
+        << "Cannot deep copy object of type \""
+        << std::string_view(type_info->type_key.data, type_info->type_key.size)
+        << "\" because it is not copy-constructible";
+    Function copy_fn = (*column_)[ti].cast<Function>();
+    Any copy = copy_fn(obj);
+    copy_map_[obj] = copy;
+    resolve_queue_.push_back(copy.as<Object>());
+    return copy;
+  }
+
+  void ResolveFields(const Object* copy_obj) {
+    const TVMFFITypeInfo* type_info = 
TVMFFIGetTypeInfo(copy_obj->type_index());
+    reflection::ForEachFieldInfo(type_info, [&](const TVMFFIFieldInfo* fi) {
+      reflection::FieldGetter getter(fi);
+      Any fv = getter(copy_obj);
+      if (fv.type_index() < TypeIndex::kTVMFFIStaticObjectBegin) return;
+      Any resolved = Resolve(fv);
+      if (!fv.same_as(resolved)) {
+        reflection::FieldSetter setter(fi);
+        setter(copy_obj, resolved);
+      }
+    });
+  }
+
+  reflection::TypeAttrColumn* column_;
+  std::unordered_map<const Object*, Any> copy_map_;
+  std::vector<const Object*> resolve_queue_;
+};
+
+Any DeepCopy(const Any& value) {
+  static reflection::TypeAttrColumn 
column(reflection::type_attr::kShallowCopy);
+  ObjectDeepCopier copier(&column);
+  return copier.Run(value);
+}
+
+TVM_FFI_STATIC_INIT_BLOCK() {
+  namespace refl = tvm::ffi::reflection;
+  refl::EnsureTypeAttrColumn(refl::type_attr::kShallowCopy);
+  refl::GlobalDef().def("ffi.DeepCopy", DeepCopy);
+}
+
+}  // namespace ffi
+}  // namespace tvm
diff --git a/src/ffi/testing/testing.cc b/src/ffi/testing/testing.cc
index f6d1ff5..3a0f488 100644
--- a/src/ffi/testing/testing.cc
+++ b/src/ffi/testing/testing.cc
@@ -161,6 +161,26 @@ class TestCxxKwOnly : public Object {
   TVM_FFI_DECLARE_OBJECT_INFO("testing.TestCxxKwOnly", TestCxxKwOnly, Object);
 };
 
+class TestDeepCopyEdgesObj : public Object {
+ public:
+  Any v_any;
+  ObjectRef v_obj;
+
+  static constexpr bool _type_mutable = true;
+  TVM_FFI_DECLARE_OBJECT_INFO("testing.TestDeepCopyEdges", 
TestDeepCopyEdgesObj, Object);
+};
+
+class TestNonCopyable : public Object {
+ public:
+  int64_t value;
+
+  explicit TestNonCopyable(int64_t value) : value(value) {}
+  TestNonCopyable(const TestNonCopyable&) = delete;
+  TestNonCopyable& operator=(const TestNonCopyable&) = delete;
+
+  TVM_FFI_DECLARE_OBJECT_INFO("testing.TestNonCopyable", TestNonCopyable, 
Object);
+};
+
 class TestUnregisteredBaseObject : public Object {
  public:
   int64_t v1;
@@ -219,8 +239,8 @@ TVM_FFI_STATIC_INIT_BLOCK() {
       .def("add_i64", &TestObjectBase::AddI64, "add_i64 method");
 
   refl::ObjectDef<TestObjectDerived>()
-      .def_ro("v_map", &TestObjectDerived::v_map)
-      .def_ro("v_array", &TestObjectDerived::v_array);
+      .def_rw("v_map", &TestObjectDerived::v_map)
+      .def_rw("v_array", &TestObjectDerived::v_array);
 
   refl::ObjectDef<TestCxxClassBase>()
       .def(refl::init<int64_t, int32_t>())
@@ -250,6 +270,14 @@ TVM_FFI_STATIC_INIT_BLOCK() {
       .def_rw("z", &TestCxxKwOnly::z)
       .def_rw("w", &TestCxxKwOnly::w);
 
+  refl::ObjectDef<TestDeepCopyEdgesObj>()
+      .def_rw("v_any", &TestDeepCopyEdgesObj::v_any)
+      .def_rw("v_obj", &TestDeepCopyEdgesObj::v_obj);
+
+  refl::ObjectDef<TestNonCopyable>()
+      .def(refl::init<int64_t>())
+      .def_ro("value", &TestNonCopyable::value);
+
   refl::ObjectDef<TestUnregisteredBaseObject>()
       .def(refl::init<int64_t>(), "Constructor of TestUnregisteredBaseObject")
       .def_ro("v1", &TestUnregisteredBaseObject::v1)
diff --git a/tests/python/test_copy.py b/tests/python/test_copy.py
new file mode 100644
index 0000000..0a8b28f
--- /dev/null
+++ b/tests/python/test_copy.py
@@ -0,0 +1,690 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ruff: noqa: D102
+"""Tests for __copy__, __deepcopy__, and __replace__ on FFI objects."""
+
+from __future__ import annotations
+
+import copy
+
+import pytest
+import tvm_ffi
+import tvm_ffi.testing
+
+
+# --------------------------------------------------------------------------- #
+#  __copy__
+# --------------------------------------------------------------------------- #
+class TestShallowCopy:
+    """Tests for copy.copy() / __copy__."""
+
+    def test_basic_fields(self) -> None:
+        pair = tvm_ffi.testing.TestIntPair(1, 2)  # ty: 
ignore[too-many-positional-arguments]
+        pair_copy = copy.copy(pair)
+        assert pair_copy.a == 1
+        assert pair_copy.b == 2
+
+    def test_creates_new_object(self) -> None:
+        pair = tvm_ffi.testing.TestIntPair(3, 7)  # ty: 
ignore[too-many-positional-arguments]
+        pair_copy = copy.copy(pair)
+        assert not pair.same_as(pair_copy)
+
+    def test_mutable_fields(self) -> None:
+        obj = tvm_ffi.testing.create_object("testing.TestObjectBase", 
v_i64=42, v_str="hello")
+        obj_copy = copy.copy(obj)
+        assert obj_copy.v_i64 == 42  # ty: ignore[unresolved-attribute]
+        assert obj_copy.v_str == "hello"  # ty: ignore[unresolved-attribute]
+        assert obj_copy.v_f64 == 10.0  # ty: ignore[unresolved-attribute]
+        assert not obj.same_as(obj_copy)
+
+    def test_mutating_copy_does_not_affect_original(self) -> None:
+        obj = tvm_ffi.testing.create_object("testing.TestObjectBase", v_i64=1, 
v_str="a")
+        obj_copy = copy.copy(obj)
+        obj_copy.v_i64 = 99  # ty: ignore[unresolved-attribute]
+        obj_copy.v_str = "z"  # ty: ignore[unresolved-attribute]
+        assert obj.v_i64 == 1  # ty: ignore[unresolved-attribute]
+        assert obj.v_str == "a"  # ty: ignore[unresolved-attribute]
+
+    def test_derived_type_preserves_type(self) -> None:
+        v_map = tvm_ffi.convert({"k": 1})
+        v_array = tvm_ffi.convert([1, 2])
+        obj = tvm_ffi.testing.create_object(
+            "testing.TestObjectDerived",
+            v_i64=5,
+            v_map=v_map,
+            v_array=v_array,
+        )
+        obj_copy = copy.copy(obj)
+        assert not obj.same_as(obj_copy)
+        assert isinstance(obj_copy, tvm_ffi.testing.TestObjectDerived)
+        assert obj_copy.v_i64 == 5
+        # shallow copy shares sub-objects
+        assert obj_copy.v_map.same_as(obj.v_map)  # ty: 
ignore[unresolved-attribute]
+        assert obj_copy.v_array.same_as(obj.v_array)  # ty: 
ignore[unresolved-attribute]
+
+    def test_auto_copy_for_cxx_class(self) -> None:
+        # _TestCxxClassBase is copy-constructible, so copy is auto-enabled
+        # Note: _TestCxxClassBase.__init__ adds 1 to v_i64 and 2 to v_i32
+        obj = tvm_ffi.testing._TestCxxClassBase(v_i64=1, v_i32=2)
+        obj_copy = copy.copy(obj)
+        assert obj_copy.v_i64 == 2
+        assert obj_copy.v_i32 == 4
+        assert not obj.same_as(obj_copy)  # ty: ignore[unresolved-attribute]
+
+    def test_non_copyable_type_raises(self) -> None:
+        obj = tvm_ffi.testing.TestNonCopyable(42)  # ty: 
ignore[too-many-positional-arguments]
+        with pytest.raises(TypeError, match="does not support copy"):
+            copy.copy(obj)
+
+
+# --------------------------------------------------------------------------- #
+#  __deepcopy__
+# --------------------------------------------------------------------------- #
+class TestDeepCopy:
+    """Tests for copy.deepcopy() / __deepcopy__."""
+
+    def test_basic_fields(self) -> None:
+        pair = tvm_ffi.testing.TestIntPair(5, 10)  # ty: 
ignore[too-many-positional-arguments]
+        pair_deep = copy.deepcopy(pair)
+        assert pair_deep.a == 5
+        assert pair_deep.b == 10
+        assert not pair.same_as(pair_deep)
+
+    def test_nested_objects_are_copied(self) -> None:
+        inner = tvm_ffi.testing.TestIntPair(1, 2)  # ty: 
ignore[too-many-positional-arguments]
+        v_array = tvm_ffi.convert([inner])
+        v_map = tvm_ffi.convert({"x": "y"})
+        obj = tvm_ffi.testing.create_object(
+            "testing.TestObjectDerived",
+            v_i64=10,
+            v_map=v_map,
+            v_array=v_array,
+        )
+        obj_deep = copy.deepcopy(obj)
+        # top-level is a new object
+        assert not obj.same_as(obj_deep)
+        # nested TestIntPair should also be a new object
+        assert not obj.v_array[0].same_as(obj_deep.v_array[0])  # ty: 
ignore[unresolved-attribute]
+        # but values are preserved
+        assert obj_deep.v_array[0].a == 1  # ty: ignore[unresolved-attribute]
+        assert obj_deep.v_array[0].b == 2  # ty: ignore[unresolved-attribute]
+
+    def test_shared_references_preserved(self) -> None:
+        """Two array slots pointing to the same object should still share 
after deepcopy."""
+        shared = tvm_ffi.testing.TestIntPair(7, 8)  # ty: 
ignore[too-many-positional-arguments]
+        v_array = tvm_ffi.convert([shared, shared])
+        v_map = tvm_ffi.convert({"a": "b"})
+        obj = tvm_ffi.testing.create_object(
+            "testing.TestObjectDerived",
+            v_i64=1,
+            v_map=v_map,
+            v_array=v_array,
+        )
+        assert obj.v_array[0].same_as(obj.v_array[1])  # ty: 
ignore[unresolved-attribute]
+
+        obj_deep = copy.deepcopy(obj)
+        # the copies should still share
+        assert obj_deep.v_array[0].same_as(obj_deep.v_array[1])  # ty: 
ignore[unresolved-attribute]
+        # but they must be distinct from the originals
+        assert not obj.v_array[0].same_as(obj_deep.v_array[0])  # ty: 
ignore[unresolved-attribute]
+
+    def test_shared_containers_preserved(self) -> None:
+        """Two array slots pointing to the same container should still share 
after deepcopy."""
+        inner = tvm_ffi.convert([1, 2, 3])
+        v_array = tvm_ffi.convert([inner, inner])
+        v_map = tvm_ffi.convert({"a": "b"})
+        obj = tvm_ffi.testing.create_object(
+            "testing.TestObjectDerived",
+            v_i64=1,
+            v_map=v_map,
+            v_array=v_array,
+        )
+        assert obj.v_array[0].same_as(obj.v_array[1])  # ty: 
ignore[unresolved-attribute]
+
+        obj_deep = copy.deepcopy(obj)
+        # the copied containers should still share identity
+        assert obj_deep.v_array[0].same_as(obj_deep.v_array[1])  # ty: 
ignore[unresolved-attribute]
+        # but they must be distinct from the originals
+        assert not obj.v_array[0].same_as(obj_deep.v_array[0])  # ty: 
ignore[unresolved-attribute]
+
+    def test_original_untouched(self) -> None:
+        obj = tvm_ffi.testing.create_object("testing.TestObjectBase", 
v_i64=42, v_str="original")
+        obj_deep = copy.deepcopy(obj)
+        obj_deep.v_i64 = 0  # ty: ignore[unresolved-attribute]
+        obj_deep.v_str = "modified"  # ty: ignore[unresolved-attribute]
+        assert obj.v_i64 == 42  # ty: ignore[unresolved-attribute]
+        assert obj.v_str == "original"  # ty: ignore[unresolved-attribute]
+
+    def test_self_referencing_cycle(self) -> None:
+        """An object whose array field contains itself should deepcopy 
correctly."""
+        v_map = tvm_ffi.convert({"a": "b"})
+        obj = tvm_ffi.testing.create_object(
+            "testing.TestObjectDerived",
+            v_i64=1,
+            v_map=v_map,
+            v_array=tvm_ffi.convert([]),
+        )
+        # Create self-reference: obj.v_array = [obj]
+        obj.v_array = tvm_ffi.convert([obj])  # ty: 
ignore[unresolved-attribute]
+
+        obj_deep = copy.deepcopy(obj)
+        assert not obj.same_as(obj_deep)
+        # The cycle should be preserved: copy -> copy.v_array[0] -> same copy
+        assert obj_deep.v_array[0].same_as(obj_deep)  # ty: 
ignore[unresolved-attribute]
+
+    def test_mutual_reference_cycle(self) -> None:
+        """Two objects referencing each other should deepcopy with cycle 
preserved."""
+        v_map = tvm_ffi.convert({"a": "b"})
+        obj_a = tvm_ffi.testing.create_object(
+            "testing.TestObjectDerived",
+            v_i64=1,
+            v_map=v_map,
+            v_array=tvm_ffi.convert([]),
+        )
+        obj_b = tvm_ffi.testing.create_object(
+            "testing.TestObjectDerived",
+            v_i64=2,
+            v_map=v_map,
+            v_array=tvm_ffi.convert([obj_a]),
+        )
+        # Close the cycle: A -> B and B -> A
+        obj_a.v_array = tvm_ffi.convert([obj_b])  # ty: 
ignore[unresolved-attribute]
+
+        deep_a = copy.deepcopy(obj_a)
+        assert not obj_a.same_as(deep_a)
+        # deep_a.v_array[0] is the copy of obj_b
+        deep_b = deep_a.v_array[0]  # ty: ignore[unresolved-attribute]
+        assert not obj_b.same_as(deep_b)
+        # The cycle should be preserved: deep_a -> deep_b -> deep_a
+        assert deep_b.v_array[0].same_as(deep_a)
+        # Values are preserved
+        assert deep_a.v_i64 == 1  # ty: ignore[unresolved-attribute]
+        assert deep_b.v_i64 == 2
+
+    def test_array_root(self) -> None:
+        """Deepcopy with a bare Array as root should create a new array."""
+        inner = tvm_ffi.testing.TestIntPair(1, 2)  # ty: 
ignore[too-many-positional-arguments]
+        arr = tvm_ffi.convert([inner, "hello", 42])
+        arr_deep = copy.deepcopy(arr)
+        assert not arr.same_as(arr_deep)
+        # inner object is deep-copied
+        assert not arr[0].same_as(arr_deep[0])
+        assert arr_deep[0].a == 1
+        # primitives and strings preserved
+        assert arr_deep[1] == "hello"
+        assert arr_deep[2] == 42
+
+    def test_map_root(self) -> None:
+        """Deepcopy with a bare Map as root should create a new map."""
+        inner = tvm_ffi.testing.TestIntPair(3, 4)  # ty: 
ignore[too-many-positional-arguments]
+        m = tvm_ffi.convert({"key": inner})
+        m_deep = copy.deepcopy(m)
+        assert not m.same_as(m_deep)
+        # inner object is deep-copied
+        assert not m["key"].same_as(m_deep["key"])
+        assert m_deep["key"].a == 3
+
+    def test_auto_deepcopy_for_cxx_class(self) -> None:
+        # _TestCxxClassBase is copy-constructible, so deepcopy is auto-enabled
+        # Note: _TestCxxClassBase.__init__ adds 1 to v_i64 and 2 to v_i32
+        obj = tvm_ffi.testing._TestCxxClassBase(v_i64=1, v_i32=2)
+        obj_deep = copy.deepcopy(obj)
+        assert obj_deep.v_i64 == 2
+        assert obj_deep.v_i32 == 4
+        assert not obj.same_as(obj_deep)  # ty: ignore[unresolved-attribute]
+
+    def test_non_copyable_type_raises(self) -> None:
+        obj = tvm_ffi.testing.TestNonCopyable(42)  # ty: 
ignore[too-many-positional-arguments]
+        with pytest.raises(TypeError, match="does not support deepcopy"):
+            copy.deepcopy(obj)
+
+    def test_long_string_in_array(self) -> None:
+        """Strings exceeding inline threshold are heap-allocated objects.
+        deepcopy must treat them as immutable terminals, not call CopyObject.
+        """
+        long_str = "a" * 100
+        arr = tvm_ffi.convert([long_str])
+        arr_deep = copy.deepcopy(arr)
+        assert not arr.same_as(arr_deep)
+        assert arr_deep[0] == long_str
+
+    def test_long_string_in_object_field(self) -> None:
+        """Heap-allocated string as a field value should survive deepcopy."""
+        long_str = "x" * 200
+        obj = tvm_ffi.testing.create_object("testing.TestObjectBase", v_i64=1, 
v_str=long_str)
+        obj_deep = copy.deepcopy(obj)
+        assert obj_deep.v_str == long_str  # ty: ignore[unresolved-attribute]
+
+    def test_any_field_with_object(self) -> None:
+        """Any-typed field containing an object must be recursively copied."""
+        inner = tvm_ffi.testing.TestIntPair(3, 4)  # ty: 
ignore[too-many-positional-arguments]
+        obj = tvm_ffi.testing.create_object("testing.TestDeepCopyEdges", 
v_any=inner, v_obj=inner)
+        obj_deep = copy.deepcopy(obj)
+        assert not obj.same_as(obj_deep)
+        assert not inner.same_as(obj_deep.v_any)  # ty: 
ignore[unresolved-attribute]
+        assert obj_deep.v_any.a == 3  # ty: ignore[unresolved-attribute]
+
+    def test_any_field_with_array(self) -> None:
+        """Any-typed field containing an Array must be recursively copied."""
+        inner_arr = tvm_ffi.convert([1, 2, 3])
+        obj = tvm_ffi.testing.create_object(
+            "testing.TestDeepCopyEdges", v_any=inner_arr, v_obj=inner_arr
+        )
+        obj_deep = copy.deepcopy(obj)
+        assert not inner_arr.same_as(obj_deep.v_any)  # ty: 
ignore[unresolved-attribute]
+        assert list(obj_deep.v_any) == [1, 2, 3]  # ty: 
ignore[unresolved-attribute]
+
+    def test_any_field_with_map(self) -> None:
+        """Any-typed field containing a Map must be recursively copied."""
+        inner_map = tvm_ffi.convert({"k": "v"})
+        obj = tvm_ffi.testing.create_object(
+            "testing.TestDeepCopyEdges", v_any=inner_map, v_obj=inner_map
+        )
+        obj_deep = copy.deepcopy(obj)
+        assert not inner_map.same_as(obj_deep.v_any)  # ty: 
ignore[unresolved-attribute]
+        assert obj_deep.v_any["k"] == "v"  # ty: ignore[unresolved-attribute]
+
+    def test_objectref_field_with_array(self) -> None:
+        """ObjectRef field holding runtime Array must go through Resolve."""
+        inner_arr = tvm_ffi.convert([10, 20])
+        obj = tvm_ffi.testing.create_object(
+            "testing.TestDeepCopyEdges", v_any=None, v_obj=inner_arr
+        )
+        obj_deep = copy.deepcopy(obj)
+        assert not inner_arr.same_as(obj_deep.v_obj)  # ty: 
ignore[unresolved-attribute]
+        assert list(obj_deep.v_obj) == [10, 20]  # ty: 
ignore[unresolved-attribute]
+
+    def test_objectref_field_with_map(self) -> None:
+        """ObjectRef field holding runtime Map must go through Resolve."""
+        inner_map = tvm_ffi.convert({"a": 1})
+        obj = tvm_ffi.testing.create_object(
+            "testing.TestDeepCopyEdges", v_any=None, v_obj=inner_map
+        )
+        obj_deep = copy.deepcopy(obj)
+        assert not inner_map.same_as(obj_deep.v_obj)  # ty: 
ignore[unresolved-attribute]
+        assert obj_deep.v_obj["a"] == 1  # ty: ignore[unresolved-attribute]
+
+    def test_any_field_sharing_preserved(self) -> None:
+        """Shared references through Any and ObjectRef fields are preserved."""
+        shared = tvm_ffi.testing.TestIntPair(5, 6)  # ty: 
ignore[too-many-positional-arguments]
+        obj = tvm_ffi.testing.create_object("testing.TestDeepCopyEdges", 
v_any=shared, v_obj=shared)
+        obj_deep = copy.deepcopy(obj)
+        # Both fields should point to the same copied object
+        assert obj_deep.v_any.same_as(obj_deep.v_obj)  # ty: 
ignore[unresolved-attribute]
+        assert not shared.same_as(obj_deep.v_any)  # ty: 
ignore[unresolved-attribute]
+
+
+# --------------------------------------------------------------------------- #
+#  Deep copy branch coverage (C++ deep_copy.cc)
+# --------------------------------------------------------------------------- #
+_deep_copy = tvm_ffi.get_global_func("ffi.DeepCopy")
+
+
+class TestDeepCopyBranches:
+    """Branch-coverage tests targeting every code path in deep_copy.cc."""
+
+    # --- Run(): primitive passthrough (type_index < kStaticObjectBegin) ---
+
+    def test_primitive_int(self) -> None:
+        assert _deep_copy(42) == 42
+
+    def test_primitive_float(self) -> None:
+        result = _deep_copy(3.14)
+        assert abs(result - 3.14) < 1e-9
+
+    def test_primitive_str(self) -> None:
+        assert _deep_copy("hello") == "hello"
+
+    def test_primitive_none(self) -> None:
+        assert _deep_copy(None) is None
+
+    def test_primitive_bool(self) -> None:
+        assert _deep_copy(True) is True
+        assert _deep_copy(False) is False
+
+    # --- Resolve(): array with various element types ---
+
+    def test_array_all_ints(self) -> None:
+        """All elements are primitives — Resolve() returns each as-is."""
+        arr = tvm_ffi.convert([1, 2, 3])
+        arr_deep = copy.deepcopy(arr)
+        assert not arr.same_as(arr_deep)
+        assert list(arr_deep) == [1, 2, 3]
+
+    def test_array_all_strings(self) -> None:
+        arr = tvm_ffi.convert(["a", "bb", "ccc"])
+        arr_deep = copy.deepcopy(arr)
+        assert not arr.same_as(arr_deep)
+        assert list(arr_deep) == ["a", "bb", "ccc"]
+
+    def test_array_with_none_elements(self) -> None:
+        arr = tvm_ffi.convert([None, 1, None])
+        arr_deep = copy.deepcopy(arr)
+        assert arr_deep[0] is None
+        assert arr_deep[1] == 1
+        assert arr_deep[2] is None
+
+    def test_array_mixed_primitive_types(self) -> None:
+        """Array with int, float, str, bool, None — all primitives."""
+        arr = tvm_ffi.convert([42, 3.14, "hi", True, None])
+        arr_deep = copy.deepcopy(arr)
+        assert not arr.same_as(arr_deep)
+        assert arr_deep[0] == 42
+        assert abs(arr_deep[1] - 3.14) < 1e-9
+        assert arr_deep[2] == "hi"
+        assert arr_deep[3] is True
+        assert arr_deep[4] is None
+
+    def test_array_mixed_with_objects_and_containers(self) -> None:
+        """Array with int, str, None, object, nested array, nested map."""
+        inner_obj = tvm_ffi.testing.TestIntPair(1, 2)  # ty: 
ignore[too-many-positional-arguments]
+        inner_arr = tvm_ffi.convert([10, 20])
+        inner_map = tvm_ffi.convert({"k": "v"})
+        arr = tvm_ffi.convert([42, "hello", None, inner_obj, inner_arr, 
inner_map])
+        arr_deep = copy.deepcopy(arr)
+        # primitives pass through
+        assert arr_deep[0] == 42
+        assert arr_deep[1] == "hello"
+        assert arr_deep[2] is None
+        # object is deep-copied
+        assert not arr[3].same_as(arr_deep[3])
+        assert arr_deep[3].a == 1
+        # nested array is deep-copied
+        assert not arr[4].same_as(arr_deep[4])
+        assert list(arr_deep[4]) == [10, 20]
+        # nested map is deep-copied
+        assert not arr[5].same_as(arr_deep[5])
+        assert arr_deep[5]["k"] == "v"
+
+    def test_array_empty(self) -> None:
+        arr = tvm_ffi.convert([])
+        arr_deep = copy.deepcopy(arr)
+        assert not arr.same_as(arr_deep)
+        assert len(arr_deep) == 0
+
+    def test_array_nested_arrays(self) -> None:
+        """Array of arrays — Resolve() recurses into each nested array."""
+        a = tvm_ffi.convert([1, 2])
+        b = tvm_ffi.convert([3, 4])
+        outer = tvm_ffi.convert([a, b])
+        outer_deep = copy.deepcopy(outer)
+        assert not outer.same_as(outer_deep)
+        assert not outer[0].same_as(outer_deep[0])
+        assert not outer[1].same_as(outer_deep[1])
+        assert list(outer_deep[0]) == [1, 2]
+        assert list(outer_deep[1]) == [3, 4]
+
+    def test_array_nested_maps(self) -> None:
+        """Array of maps."""
+        m = tvm_ffi.convert({"x": 1})
+        arr = tvm_ffi.convert([m])
+        arr_deep = copy.deepcopy(arr)
+        assert not arr[0].same_as(arr_deep[0])
+        assert arr_deep[0]["x"] == 1
+
+    # --- Resolve(): map with various key/value types ---
+
+    def test_map_primitive_keys_and_values(self) -> None:
+        m = tvm_ffi.convert({"a": 1, "b": 2, "c": 3})
+        m_deep = copy.deepcopy(m)
+        assert not m.same_as(m_deep)
+        assert m_deep["a"] == 1
+        assert m_deep["b"] == 2
+        assert m_deep["c"] == 3
+
+    def test_map_with_container_values(self) -> None:
+        inner_arr = tvm_ffi.convert([1, 2])
+        m = tvm_ffi.convert({"arr": inner_arr})
+        m_deep = copy.deepcopy(m)
+        assert not m["arr"].same_as(m_deep["arr"])
+        assert list(m_deep["arr"]) == [1, 2]
+
+    def test_map_with_none_values(self) -> None:
+        m = tvm_ffi.convert({"a": None, "b": 1})
+        m_deep = copy.deepcopy(m)
+        assert not m.same_as(m_deep)
+        assert m_deep["a"] is None
+        assert m_deep["b"] == 1
+
+    def test_map_empty(self) -> None:
+        m = tvm_ffi.convert({})
+        m_deep = copy.deepcopy(m)
+        assert not m.same_as(m_deep)
+        assert len(m_deep) == 0
+
+    # --- Resolve(): copy_map_ hit (shared references across containers) ---
+
+    def test_shared_array_identity_in_outer_array(self) -> None:
+        """Same array appears 3 times — all copies share identity."""
+        shared = tvm_ffi.convert([1, 2])
+        outer = tvm_ffi.convert([shared, shared, shared])
+        outer_deep = copy.deepcopy(outer)
+        assert outer_deep[0].same_as(outer_deep[1])
+        assert outer_deep[1].same_as(outer_deep[2])
+        assert not outer[0].same_as(outer_deep[0])
+
+    def test_shared_map_identity_in_outer_array(self) -> None:
+        shared = tvm_ffi.convert({"x": 1})
+        outer = tvm_ffi.convert([shared, shared])
+        outer_deep = copy.deepcopy(outer)
+        assert outer_deep[0].same_as(outer_deep[1])
+        assert not outer[0].same_as(outer_deep[0])
+
+    def test_shared_object_across_array_and_map(self) -> None:
+        """Same object referenced from both v_array and v_map."""
+        pair = tvm_ffi.testing.TestIntPair(7, 8)  # ty: 
ignore[too-many-positional-arguments]
+        v_array = tvm_ffi.convert([pair])
+        v_map = tvm_ffi.convert({"p": pair})
+        obj = tvm_ffi.testing.create_object(
+            "testing.TestObjectDerived",
+            v_i64=1,
+            v_map=v_map,
+            v_array=v_array,
+        )
+        obj_deep = copy.deepcopy(obj)
+        # both fields should refer to the same copied object
+        deep_from_arr = obj_deep.v_array[0]  # ty: ignore[unresolved-attribute]
+        deep_from_map = obj_deep.v_map["p"]  # ty: ignore[unresolved-attribute]
+        assert deep_from_arr.same_as(deep_from_map)
+        assert not pair.same_as(deep_from_arr)
+
+    # --- ResolveFields: container field with only primitives ---
+    #     Resolve() always rebuilds the container, so setter is always called.
+
+    def test_field_array_only_primitives(self) -> None:
+        v_array = tvm_ffi.convert([1, 2, 3])
+        v_map = tvm_ffi.convert({"k": "v"})
+        obj = tvm_ffi.testing.create_object(
+            "testing.TestObjectDerived",
+            v_i64=1,
+            v_map=v_map,
+            v_array=v_array,
+        )
+        obj_deep = copy.deepcopy(obj)
+        assert not obj.v_array.same_as(obj_deep.v_array)  # ty: 
ignore[unresolved-attribute]
+        assert list(obj_deep.v_array) == [1, 2, 3]  # ty: 
ignore[unresolved-attribute]
+
+    def test_field_map_only_primitives(self) -> None:
+        v_array = tvm_ffi.convert([])
+        v_map = tvm_ffi.convert({"x": 1, "y": 2})
+        obj = tvm_ffi.testing.create_object(
+            "testing.TestObjectDerived",
+            v_i64=1,
+            v_map=v_map,
+            v_array=v_array,
+        )
+        obj_deep = copy.deepcopy(obj)
+        assert not obj.v_map.same_as(obj_deep.v_map)  # ty: 
ignore[unresolved-attribute]
+        assert obj_deep.v_map["x"] == 1  # ty: ignore[unresolved-attribute]
+        assert obj_deep.v_map["y"] == 2  # ty: ignore[unresolved-attribute]
+
+    # --- ResolveFields: empty container fields ---
+
+    def test_field_empty_containers(self) -> None:
+        v_array = tvm_ffi.convert([])
+        v_map = tvm_ffi.convert({})
+        obj = tvm_ffi.testing.create_object(
+            "testing.TestObjectDerived",
+            v_i64=1,
+            v_map=v_map,
+            v_array=v_array,
+        )
+        obj_deep = copy.deepcopy(obj)
+        assert not obj.v_array.same_as(obj_deep.v_array)  # ty: 
ignore[unresolved-attribute]
+        assert not obj.v_map.same_as(obj_deep.v_map)  # ty: 
ignore[unresolved-attribute]
+        assert len(obj_deep.v_array) == 0  # ty: ignore[unresolved-attribute]
+        assert len(obj_deep.v_map) == 0  # ty: ignore[unresolved-attribute]
+
+    # --- ResolveFields: shared container across multiple objects ---
+
+    def test_shared_container_field_across_objects(self) -> None:
+        """Two objects share the same v_array — copy_map_ deduplicates."""
+        shared_arr = tvm_ffi.convert([1, 2, 3])
+        obj_a = tvm_ffi.testing.create_object(
+            "testing.TestObjectDerived",
+            v_i64=1,
+            v_map=tvm_ffi.convert({}),
+            v_array=shared_arr,
+        )
+        obj_b = tvm_ffi.testing.create_object(
+            "testing.TestObjectDerived",
+            v_i64=2,
+            v_map=tvm_ffi.convert({}),
+            v_array=shared_arr,
+        )
+        outer = tvm_ffi.convert([obj_a, obj_b])
+        outer_deep = copy.deepcopy(outer)
+        deep_a = outer_deep[0]
+        deep_b = outer_deep[1]
+        # both should share the same deep-copied array
+        assert deep_a.v_array.same_as(deep_b.v_array)
+        assert not shared_arr.same_as(deep_a.v_array)
+
+    # --- CopyObject: unsupported type nested in container ---
+
+    def test_cxx_class_in_array(self) -> None:
+        # Note: _TestCxxClassBase.__init__ adds 1 to v_i64 and 2 to v_i32
+        obj = tvm_ffi.testing._TestCxxClassBase(v_i64=1, v_i32=2)
+        arr = tvm_ffi.convert([obj])
+        arr_deep = copy.deepcopy(arr)
+        assert not arr.same_as(arr_deep)
+        assert not arr[0].same_as(arr_deep[0])
+        assert arr_deep[0].v_i64 == 2
+        assert arr_deep[0].v_i32 == 4
+
+    def test_cxx_class_in_map_value(self) -> None:
+        # Note: _TestCxxClassBase.__init__ adds 1 to v_i64 and 2 to v_i32
+        obj = tvm_ffi.testing._TestCxxClassBase(v_i64=1, v_i32=2)
+        m = tvm_ffi.convert({"k": obj})
+        m_deep = copy.deepcopy(m)
+        assert not m.same_as(m_deep)
+        assert not m["k"].same_as(m_deep["k"])
+        assert m_deep["k"].v_i64 == 2
+        assert m_deep["k"].v_i32 == 4
+
+    def test_non_copyable_type_in_array(self) -> None:
+        obj = tvm_ffi.testing.TestNonCopyable(1)  # ty: 
ignore[too-many-positional-arguments]
+        arr = tvm_ffi.convert([obj])
+        with pytest.raises(RuntimeError, match="not copy-constructible"):
+            copy.deepcopy(arr)
+
+    def test_non_copyable_type_in_map_value(self) -> None:
+        obj = tvm_ffi.testing.TestNonCopyable(1)  # ty: 
ignore[too-many-positional-arguments]
+        m = tvm_ffi.convert({"k": obj})
+        with pytest.raises(RuntimeError, match="not copy-constructible"):
+            copy.deepcopy(m)
+
+    # --- Deeply nested structures ---
+
+    def test_deeply_nested_containers(self) -> None:
+        """Array > Map > Array > object — all levels resolved."""
+        pair = tvm_ffi.testing.TestIntPair(9, 10)  # ty: 
ignore[too-many-positional-arguments]
+        inner_arr = tvm_ffi.convert([pair])
+        inner_map = tvm_ffi.convert({"items": inner_arr})
+        outer = tvm_ffi.convert([inner_map])
+        outer_deep = copy.deepcopy(outer)
+        deep_pair = outer_deep[0]["items"][0]
+        assert not pair.same_as(deep_pair)
+        assert deep_pair.a == 9
+        assert deep_pair.b == 10
+
+    def test_object_with_deeply_nested_field(self) -> None:
+        """Object whose array field contains a map containing an object."""
+        pair = tvm_ffi.testing.TestIntPair(5, 6)  # ty: 
ignore[too-many-positional-arguments]
+        inner_map = tvm_ffi.convert({"pair": pair})
+        v_array = tvm_ffi.convert([inner_map])
+        obj = tvm_ffi.testing.create_object(
+            "testing.TestObjectDerived",
+            v_i64=1,
+            v_map=tvm_ffi.convert({}),
+            v_array=v_array,
+        )
+        obj_deep = copy.deepcopy(obj)
+        deep_pair = obj_deep.v_array[0]["pair"]  # ty: 
ignore[unresolved-attribute]
+        assert not pair.same_as(deep_pair)
+        assert deep_pair.a == 5
+
+
+# --------------------------------------------------------------------------- #
+#  __replace__
+# --------------------------------------------------------------------------- #
+class TestReplace:
+    """Tests for __replace__."""
+
+    def test_replace_writable_fields(self) -> None:
+        obj = tvm_ffi.testing.create_object("testing.TestObjectBase", v_i64=1, 
v_str="a")
+        obj2 = obj.__replace__(v_i64=99)  # ty: ignore[unresolved-attribute]
+        assert obj2.v_i64 == 99
+        assert obj2.v_str == "a"
+        assert not obj.same_as(obj2)
+
+    def test_replace_multiple_fields(self) -> None:
+        obj = tvm_ffi.testing.create_object("testing.TestObjectBase", v_i64=1, 
v_str="a")
+        obj2 = obj.__replace__(v_i64=42, v_str="world")  # ty: 
ignore[unresolved-attribute]
+        assert obj2.v_i64 == 42
+        assert obj2.v_str == "world"
+
+    def test_replace_no_kwargs_is_copy(self) -> None:
+        obj = tvm_ffi.testing.create_object("testing.TestObjectBase", v_i64=7, 
v_str="hi")
+        obj2 = obj.__replace__()  # ty: ignore[unresolved-attribute]
+        assert obj2.v_i64 == 7
+        assert obj2.v_str == "hi"
+        assert not obj.same_as(obj2)
+
+    def test_original_unchanged(self) -> None:
+        obj = tvm_ffi.testing.create_object("testing.TestObjectBase", v_i64=5, 
v_str="x")
+        obj.__replace__(v_i64=100)  # ty: ignore[unresolved-attribute]
+        assert obj.v_i64 == 5  # ty: ignore[unresolved-attribute]
+
+    def test_replace_readonly_field_raises(self) -> None:
+        pair = tvm_ffi.testing.TestIntPair(3, 4)  # ty: 
ignore[too-many-positional-arguments]
+        with pytest.raises(AttributeError):
+            pair.__replace__(a=10)  # ty: ignore[unresolved-attribute]
+
+    def test_auto_replace_for_cxx_class(self) -> None:
+        # _TestCxxClassBase is copy-constructible, so replace is auto-enabled
+        # Note: _TestCxxClassBase.__init__ adds 1 to v_i64 and 2 to v_i32
+        obj = tvm_ffi.testing._TestCxxClassBase(v_i64=1, v_i32=2)
+        obj2 = obj.__replace__(v_i64=99)  # ty: ignore[unresolved-attribute]
+        assert obj2.v_i64 == 99
+        assert obj2.v_i32 == 4
+        assert not obj.same_as(obj2)  # ty: ignore[unresolved-attribute]
+
+    def test_non_copyable_type_raises(self) -> None:
+        obj = tvm_ffi.testing.TestNonCopyable(42)  # ty: 
ignore[too-many-positional-arguments]
+        with pytest.raises(TypeError, match="does not support replace"):
+            obj.__replace__()  # ty: ignore[unresolved-attribute]

Reply via email to