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]