diff --git a/rdagent/components/coder/factor_coder/__init__.py b/rdagent/components/coder/factor_coder/__init__.py index 2c26cae43..26a3fcee8 100644 --- a/rdagent/components/coder/factor_coder/__init__.py +++ b/rdagent/components/coder/factor_coder/__init__.py @@ -1,3 +1,13 @@ +from rdagent.components.coder.factor_coder._costeer_compat import ( + install_compat_redirector, +) + +# Re-route the pre-refactor `rdagent.components.coder.factor_coder.CoSTEER.*` +# import path to the new `rdagent.components.coder.CoSTEER.*` package so that +# pre-existing pickled traces (e.g. the `demo_traces` archive referenced in +# the README) continue to deserialise under `rdagent ui`. See #1331. +install_compat_redirector() + from rdagent.components.coder.CoSTEER import CoSTEER from rdagent.components.coder.CoSTEER.evaluators import CoSTEERMultiEvaluator from rdagent.components.coder.factor_coder.config import FACTOR_COSTEER_SETTINGS diff --git a/rdagent/components/coder/factor_coder/_costeer_compat.py b/rdagent/components/coder/factor_coder/_costeer_compat.py new file mode 100644 index 000000000..34eb67118 --- /dev/null +++ b/rdagent/components/coder/factor_coder/_costeer_compat.py @@ -0,0 +1,61 @@ +"""Backward-compatibility shim for the pre-refactor CoSTEER module path. + +Traces pickled before the CoSTEER relocation reference +``rdagent.components.coder.factor_coder.CoSTEER.*`` (the old location). +After the refactor the package lives at ``rdagent.components.coder.CoSTEER.*``, +so loading those pickles in ``rdagent ui`` fails with:: + + ModuleNotFoundError: No module named + 'rdagent.components.coder.factor_coder.CoSTEER' + +— see #1331. Install a ``sys.meta_path`` finder that transparently redirects +imports under the old path to the matching submodule under the new one, so +the pre-existing demo traces continue to deserialise without anyone having +to roll back to the legacy commit. +""" + +from __future__ import annotations + +import importlib +import importlib.abc +import importlib.machinery +import importlib.util +import sys +from types import ModuleType +from typing import Optional, Sequence + +_OLD_PREFIX = "rdagent.components.coder.factor_coder.CoSTEER" +_NEW_PREFIX = "rdagent.components.coder.CoSTEER" + + +class _CoSTEERPathRedirector(importlib.abc.MetaPathFinder, importlib.abc.Loader): + """Resolve ``[.submodule]`` imports against the new package.""" + + def find_spec( + self, + fullname: str, + path: Optional[Sequence[str]] = None, + target: Optional[ModuleType] = None, + ) -> Optional[importlib.machinery.ModuleSpec]: + if fullname != _OLD_PREFIX and not fullname.startswith(_OLD_PREFIX + "."): + return None + new_name = _NEW_PREFIX + fullname[len(_OLD_PREFIX):] + target_module = importlib.import_module(new_name) + sys.modules[fullname] = target_module + spec = importlib.util.spec_from_loader(fullname, self, is_package=hasattr(target_module, "__path__")) + return spec + + def create_module(self, spec: importlib.machinery.ModuleSpec) -> Optional[ModuleType]: + # The module has already been populated by find_spec via the redirect + # target, so we just hand it back to the import machinery. + return sys.modules.get(spec.name) + + def exec_module(self, module: ModuleType) -> None: + # No-op: the redirect target is fully initialised already. + return None + + +def install_compat_redirector() -> None: + """Install the redirector once; safe to call multiple times.""" + if not any(isinstance(f, _CoSTEERPathRedirector) for f in sys.meta_path): + sys.meta_path.append(_CoSTEERPathRedirector()) diff --git a/test/utils/coder/test_costeer_path_compat.py b/test/utils/coder/test_costeer_path_compat.py new file mode 100644 index 000000000..5c3b049da --- /dev/null +++ b/test/utils/coder/test_costeer_path_compat.py @@ -0,0 +1,74 @@ +"""Regression test for the pre-refactor CoSTEER import-path shim (see #1331). + +The ``demo_traces`` archive linked from the README was pickled before +``rdagent.components.coder.factor_coder.CoSTEER`` was relocated to +``rdagent.components.coder.CoSTEER``. Loading those pickles in +``rdagent ui`` would otherwise fail with ``ModuleNotFoundError`` for the +old module path. The shim installs a meta-path finder that redirects the +old path to the new package. +""" + +import importlib +import pickle +import sys +import unittest + + +class TestCoSTEERPathCompat(unittest.TestCase): + OLD = "rdagent.components.coder.factor_coder.CoSTEER" + NEW = "rdagent.components.coder.CoSTEER" + + def setUp(self) -> None: + # Importing the package triggers install_compat_redirector(). + import rdagent.components.coder.factor_coder # noqa: F401 + + def test_old_package_path_resolves_to_new_package(self) -> None: + old = importlib.import_module(self.OLD) + new = importlib.import_module(self.NEW) + self.assertIs(old, new) + + def test_old_submodule_paths_resolve_to_new_submodules(self) -> None: + for sub in ["evaluators", "evolving_strategy", "knowledge_management", "task", "config"]: + with self.subTest(submodule=sub): + old = importlib.import_module(f"{self.OLD}.{sub}") + new = importlib.import_module(f"{self.NEW}.{sub}") + self.assertIs(old, new) + + def test_pickle_referencing_old_module_path_round_trips(self) -> None: + # Locate a real class from the new module path… + new_evaluators = importlib.import_module(f"{self.NEW}.evaluators") + # Pick any concrete class exposed at module scope. + target_cls = next( + ( + obj + for name, obj in vars(new_evaluators).items() + if isinstance(obj, type) and obj.__module__ == new_evaluators.__name__ + ), + None, + ) + self.assertIsNotNone( + target_cls, + "Expected at least one concrete class on the CoSTEER evaluators module", + ) + + # …re-stamp it as if it had been defined under the *old* import path + # (this is what a pre-refactor pickle records in its serialised form). + original_module = target_cls.__module__ + try: + target_cls.__module__ = f"{self.OLD}.evaluators" + + # Drop any cached old-path module entries so we exercise the + # full meta-path lookup, the same way pickle.load() does on a + # fresh process. + for key in [k for k in sys.modules if k == self.OLD or k.startswith(self.OLD + ".")]: + del sys.modules[key] + + data = pickle.dumps(target_cls) + loaded = pickle.loads(data) + self.assertIs(loaded, target_cls) + finally: + target_cls.__module__ = original_module + + +if __name__ == "__main__": + unittest.main()