From 08f41feec933a7543e30516f1804b55309960030 Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Sat, 26 Sep 2026 02:10:04 +0200 Subject: [PATCH 1/4] Add hook _on_context_change --- xobjects/hybrid_class.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/xobjects/hybrid_class.py b/xobjects/hybrid_class.py index f83086c..6f8c3ee 100644 --- a/xobjects/hybrid_class.py +++ b/xobjects/hybrid_class.py @@ -3,6 +3,7 @@ # Copyright (c) CERN, 2021. # # ########################################### # +from hashlib import new import json from inspect import isclass @@ -214,11 +215,20 @@ def move(self, _context=None, _buffer=None, _offset=None): "references to other objects." ) + old_context = self._context + self._xobject = self._xobject.__class__( self._xobject, _context=_context, _buffer=_buffer, _offset=_offset ) self._reinit_from_xobject(_xobject=self._xobject) + new_context = self._context + if new_context is not old_context: + self._on_context_change( + old_context=old_context, + new_context=new_context, + ) + @property def _move_to(self): raise NameError("`_move_to` has been removed. Use `move` instead.") @@ -278,6 +288,7 @@ def xoinitialize(self, _xobject=None, _kwargs_name_check=True, **kwargs): def __init__(self, _xobject=None, **kwargs): self.xoinitialize(_xobject=_xobject, **kwargs) + self._on_context_change(old_context=None, new_context=self._context) def to_dict(self, copy_to_cpu=True): out = {"__class__": self.__class__.__name__} @@ -336,8 +347,9 @@ def from_dict(cls, dct, _context=None, _buffer=None, _offset=None): ) def copy(self, _context=None, _buffer=None, _offset=None): + old_context = self._context if _context is None and _buffer is None: - _context = self._xobject._buffer.context + _context = old_context # This makes a copy of the xobject new_xobject = self._XoStruct( self._xobject, _context=_context, _buffer=_buffer, _offset=_offset @@ -350,6 +362,13 @@ def copy(self, _context=None, _buffer=None, _offset=None): if hasattr(vv, "copy"): new.__dict__[kk] = vv.copy() new._xobject = new_xobject + + new_context = new._context + if new_context is not old_context: + new._on_context_change( + old_context=old_context, + new_context=new_context, + ) return new @property @@ -372,6 +391,10 @@ def __setstate__(self, state): buffer=state[0], offset=state[1] ) self._reinit_from_xobject(_xobject=self._xobject) + self._on_context_change( + old_context=None, + new_context=self._context, + ) @property def XoStruct(self): @@ -418,3 +441,18 @@ def __repr__(self): vvrepr = repr(vv) args.append(f"{fname}={vvrepr}") return f'{type(self).__name__}({", ".join(args)})' + + def _on_context_change(self, old_context, new_context): + """Hook called after the backing xobject changes context. + It may inspect the backing Xobject/context, but should not assume that + arbitrary pure-Python subclass state has already been initialised. + + Parameters + ---------- + old_context : XContext or None + Previous context. None when the object is first initialised + or reconstructed. + new_context : XContext + Context containing the object's current backing xobject. + """ + pass From 424bce640bf235379242b9dd48c1cd35c11e3c01 Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Sat, 26 Sep 2026 02:23:22 +0200 Subject: [PATCH 2/4] Move hook from init to xoinitialise --- xobjects/hybrid_class.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/xobjects/hybrid_class.py b/xobjects/hybrid_class.py index 6f8c3ee..e06af3a 100644 --- a/xobjects/hybrid_class.py +++ b/xobjects/hybrid_class.py @@ -286,9 +286,10 @@ def xoinitialize(self, _xobject=None, _kwargs_name_check=True, **kwargs): # (for example in case object is initialized from dict) self._reinit_from_xobject(_xobject=self._xobject) + self._on_context_change(old_context=None, new_context=self._context) + def __init__(self, _xobject=None, **kwargs): self.xoinitialize(_xobject=_xobject, **kwargs) - self._on_context_change(old_context=None, new_context=self._context) def to_dict(self, copy_to_cpu=True): out = {"__class__": self.__class__.__name__} From 6d1223a6473f93ce152dc41fc14727dc7e56406c Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Sat, 26 Sep 2026 02:31:13 +0200 Subject: [PATCH 3/4] Forgot to add hook inside first branch of xoinitialise --- xobjects/hybrid_class.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/xobjects/hybrid_class.py b/xobjects/hybrid_class.py index e06af3a..a345e89 100644 --- a/xobjects/hybrid_class.py +++ b/xobjects/hybrid_class.py @@ -224,10 +224,7 @@ def move(self, _context=None, _buffer=None, _offset=None): new_context = self._context if new_context is not old_context: - self._on_context_change( - old_context=old_context, - new_context=new_context, - ) + self._on_context_change(old_context, new_context) @property def _move_to(self): @@ -265,6 +262,7 @@ def xoinitialize(self, _xobject=None, _kwargs_name_check=True, **kwargs): if _xobject is not None: self._reinit_from_xobject(_xobject=_xobject) + self._on_context_change(None, self._context) return # Handle dressed inputs @@ -286,7 +284,7 @@ def xoinitialize(self, _xobject=None, _kwargs_name_check=True, **kwargs): # (for example in case object is initialized from dict) self._reinit_from_xobject(_xobject=self._xobject) - self._on_context_change(old_context=None, new_context=self._context) + self._on_context_change(None, self._context) def __init__(self, _xobject=None, **kwargs): self.xoinitialize(_xobject=_xobject, **kwargs) @@ -348,7 +346,7 @@ def from_dict(cls, dct, _context=None, _buffer=None, _offset=None): ) def copy(self, _context=None, _buffer=None, _offset=None): - old_context = self._context + old_context = self._xobject._buffer.context if _context is None and _buffer is None: _context = old_context # This makes a copy of the xobject @@ -366,10 +364,7 @@ def copy(self, _context=None, _buffer=None, _offset=None): new_context = new._context if new_context is not old_context: - new._on_context_change( - old_context=old_context, - new_context=new_context, - ) + new._on_context_change(old_context, new_context) return new @property @@ -392,10 +387,7 @@ def __setstate__(self, state): buffer=state[0], offset=state[1] ) self._reinit_from_xobject(_xobject=self._xobject) - self._on_context_change( - old_context=None, - new_context=self._context, - ) + self._on_context_change(None, self._context) @property def XoStruct(self): From 407f42cfd1bfb46702d0669cee5cf0e682ef6a6a Mon Sep 17 00:00:00 2001 From: "Frederik F. Van der Veken" Date: Sat, 26 Sep 2026 02:32:37 +0200 Subject: [PATCH 4/4] Added test --- tests/test_hybrid_class.py | 93 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/test_hybrid_class.py b/tests/test_hybrid_class.py index f4b0676..b388c10 100644 --- a/tests/test_hybrid_class.py +++ b/tests/test_hybrid_class.py @@ -405,3 +405,96 @@ def c(self): assert all(td3.a == td2.a) assert td3.b == td2.b assert td3.c == td2.c + + +def test_on_context_change(): + class TestClass(xo.HybridClass): + _xofields = { + "value": xo.Float64, + } + + def __init__(self, **kwargs): + self.context_changes = [] + super().__init__(**kwargs) + + def _on_context_change(self, old_context, new_context): + self.context_changes.append((old_context, new_context)) + + context0 = xo.ContextCpu() + context1 = xo.ContextCpu(omp_num_threads="auto") + + # Initial construction + obj = TestClass(value=3.0, _context=context0) + + assert obj._context is context0 + assert obj.context_changes == [ + (None, context0), + ] + + # Move to a different context + obj.move(_context=context1) + + assert obj._context is context1 + assert obj.context_changes == [ + (None, context0), + (context0, context1), + ] + + # Moving within the same context should not trigger the hook + buffer1 = context1.new_buffer() + obj.move(_buffer=buffer1) + + assert obj._context is context1 + assert obj.context_changes == [ + (None, context0), + (context0, context1), + ] + + # Copy to another context. The hook must run on the copy, + # without modifying the source object's history. + copied = obj.copy(_context=context0) + + assert copied is not obj + assert copied._context is context0 + + assert obj.context_changes == [ + (None, context0), + (context0, context1), + ] + + assert copied.context_changes == [ + (None, context0), + (context0, context1), + (context1, context0), + ] + + # A copy in the same context should not add another event. + copied_same_context = obj.copy() + + assert copied_same_context._context is context1 + assert copied_same_context.context_changes == [ + (None, context0), + (context0, context1), + ] + + +def test_on_context_change_with_direct_xoinitialize(): + class TestClass(xo.HybridClass): + _xofields = { + "value": xo.Float64, + } + + def __init__(self, **kwargs): + self.context_changes = [] + self.xoinitialize(**kwargs) + + def _on_context_change(self, old_context, new_context): + self.context_changes.append((old_context, new_context)) + + context = xo.ContextCpu() + + obj = TestClass(value=3.0, _context=context) + + assert obj.context_changes == [ + (None, context), + ]