Coverage for src/vivarium_compat/_compat.py: 69%

52 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-17 17:03 +0000

1"""Backward-compatible import redirects for the vivarium monorepo migration. 

2 

3Intercepts old-style imports (e.g. ``import layered_config_tree``, 

4``from vivarium_public_health.disease import DiseaseModel``) and transparently 

5redirects them to the new ``vivarium.*`` namespace, emitting a DeprecationWarning. 

6 

7Activated at interpreter startup via ``vivarium_compat.pth`` so the hook is in 

8place before any user code runs. 

9 

10To add a redirect when a package migrates, add an entry to ``_REDIRECTS`` and 

11bump the ``vivarium-compat`` version in ``pyproject.toml``. Entries are safe 

12to add *before* the target package is released: if the new module isn't 

13importable, the hook falls back to letting the old on-disk package resolve 

14normally, so installations during the transition window aren't broken. 

15 

16Remove this module once all downstream packages have released versions that use 

17the new import paths and the deprecation period has ended. 

18""" 

19 

20import importlib 

21import importlib.abc 

22import importlib.machinery 

23import sys 

24import warnings 

25from types import ModuleType 

26 

27# Old import root -> new import root. 

28# Entries here are safe to populate ahead of the target package's release: 

29# if the new target isn't installed yet, the hook falls back to the old 

30# package's normal on-disk location (so existing installations during the 

31# transition window keep working). 

32_REDIRECTS: dict[str, str] = { 

33 # Renamed top-level packages 

34 "vivarium_profiling": "vivarium.profiling", 

35 "risk_distributions": "vivarium.risk_distributions", 

36 "gbd_mapping": "vivarium.gbd_mapping", 

37 "gbd_mapping_generator": "vivarium.gbd_mapping_generator", 

38 # FIXME: Temporarily remove lct and vivarium redirects 

39 # "layered_config_tree": "vivarium.config_tree", 

40 # # vivarium -> vivarium-engine: top-level submodules moved under 

41 # # vivarium.engine.*; the entries below redirect submodule imports 

42 # # (e.g. ``from vivarium.framework.engine import Builder``). 

43 # # NOTE: Attribute imports off the bare namespace (e.g. ``from vivarium 

44 # # import Component``) are handled separately by the ``__getattr__`` 

45 # # in vivarium-engine's ``vivarium/__init__.py``; the compat hook 

46 # # cannot intercept those without breaking sibling-namespace lookups. 

47 # "vivarium.examples": "vivarium.engine.examples", 

48 # "vivarium.framework": "vivarium.engine.framework", 

49 # "vivarium.interface": "vivarium.engine.interface", 

50 # "vivarium.component": "vivarium.engine.component", 

51 # "vivarium.exceptions": "vivarium.engine.exceptions", 

52 # "vivarium.manager": "vivarium.engine.manager", 

53 # "vivarium.testing_utilities": "vivarium.engine.testing_utilities", 

54 # "vivarium.types": "vivarium.engine.types", 

55 # Not-yet-migrated libs; uncomment as each lands in the monorepo: 

56 # "vivarium_public_health": "vivarium.public_health", 

57 # "vivarium_cluster_tools": "vivarium.cluster_tools", 

58 # "vivarium_testing_utils": "vivarium.testing_utils", 

59 # "vivarium_helpers": "vivarium.helpers", 

60} 

61 

62# Tracks which old names are currently being resolved to prevent infinite 

63# recursion if a redirect target somehow re-triggers the same old-name import. 

64_resolving: set[str] = set() 

65 

66 

67def _match(fullname: str) -> tuple[str, str] | None: 

68 """Return (old_prefix, new_prefix) if fullname matches a redirect, else None. 

69 

70 Longer prefixes take precedence so more-specific entries win over broader ones. 

71 """ 

72 for old, new in sorted(_REDIRECTS.items(), key=lambda x: -len(x[0])): 

73 if fullname == old or fullname.startswith(old + "."): 

74 return old, new 

75 return None 

76 

77 

78class _CompatFinder(importlib.abc.MetaPathFinder): 

79 """Meta-path finder that redirects deprecated import paths to new locations.""" 

80 

81 def find_spec( 

82 self, 

83 fullname: str, 

84 path: object, 

85 target: ModuleType | None = None, 

86 ) -> importlib.machinery.ModuleSpec | None: 

87 match = _match(fullname) 

88 if match is None: 

89 return None 

90 

91 old_prefix, new_prefix = match 

92 new_name = new_prefix + fullname[len(old_prefix) :] 

93 

94 # Only warn on first import. After exec_module runs, sys.modules[fullname] 

95 # is set to the real module, so subsequent imports return it directly without 

96 # ever reaching find_spec again. This branch fires only in unusual re-entry 

97 # scenarios (e.g. reload()). 

98 if fullname not in sys.modules: 

99 # stacklevel=2 points into importlib internals rather than the caller's 

100 # import statement - the exact depth is CPython-version-dependent. 

101 # The warning message itself is the actionable part. 

102 warnings.warn( 

103 f"'{fullname}' has moved to '{new_name}'. " 

104 "Update your imports. This redirect will be removed in a future release.", 

105 DeprecationWarning, 

106 stacklevel=2, 

107 ) 

108 # submodule_search_locations is intentionally not set on the spec. CPython's 

109 # import machinery calls exec_module before checking __path__ on any child import, 

110 # and exec_module replaces sys.modules[fullname] with the real module (which already 

111 # has the correct __path__). Setting it to [] would be a no-op at best and misleading 

112 # at worst since we don't know at spec-creation time whether the target is a package. 

113 return importlib.machinery.ModuleSpec( 

114 fullname, _CompatLoader(fullname, new_name) 

115 ) 

116 

117 

118class _CompatLoader(importlib.abc.Loader): 

119 """Loads the real module at the new location and aliases it under the old name.""" 

120 

121 def __init__(self, old_name: str, new_name: str) -> None: 

122 self._old_name = old_name 

123 self._new_name = new_name 

124 

125 def exec_module(self, module: ModuleType) -> None: 

126 if self._old_name in _resolving: 

127 raise ImportError( 

128 f"Circular redirect detected: '{self._old_name}' -> '{self._new_name}'" 

129 ) 

130 _resolving.add(self._old_name) 

131 try: 

132 try: 

133 real = importlib.import_module(self._new_name) 

134 except ModuleNotFoundError: 

135 # FIXME: MIC-7100 Revert when all packages are migrated 

136 # New target isn't installed. Fall back to the old name's actual 

137 # on-disk location so installations during the transition window 

138 # — old package still installed, new package not yet released — 

139 # keep working. The DeprecationWarning was already emitted in 

140 # find_spec(); users will see it whether or not the fallback fires. 

141 real = _import_bypassing_compat(self._old_name) 

142 # Register under the old name so subsequent imports hit sys.modules directly. 

143 sys.modules[self._old_name] = real 

144 # Defensive: covers the case where another import hook holds a direct 

145 # reference to the placeholder module object rather than re-reading sys.modules. 

146 module.__dict__.update(real.__dict__) 

147 # __spec__.name will show the new name (e.g. "vivarium.config_tree"), not the 

148 # old one. This is intentional: sys.modules[old_name] IS real, so its metadata 

149 # correctly describes its actual location. Users inspecting __spec__ after 

150 # migrating their imports will see the right thing. 

151 module.__spec__ = real.__spec__ 

152 finally: 

153 _resolving.discard(self._old_name) 

154 

155 

156def _import_bypassing_compat(name: str) -> ModuleType: 

157 """Import `name` without going through our compat finder. 

158 

159 Used to fall back to a name's real on-disk location when the redirect target 

160 isn't installed. Temporarily removes our finder from sys.meta_path so the 

161 standard PathFinder resolves the name directly. Also clears any placeholder 

162 sys.modules entry the import system set when our find_spec returned a spec. 

163 """ 

164 sys.modules.pop(name, None) 

165 saved: list[tuple[int, _CompatFinder]] = [ 

166 (i, f) for i, f in enumerate(sys.meta_path) if isinstance(f, _CompatFinder) 

167 ] 

168 for _, f in saved: 

169 sys.meta_path.remove(f) 

170 try: 

171 return importlib.import_module(name) 

172 finally: 

173 # Reinsert in original order so any other meta_path entries stay in place. 

174 for i, f in saved: 

175 sys.meta_path.insert(i, f) 

176 

177 

178def install_compat_finder() -> None: 

179 """Install the compat finder into sys.meta_path (idempotent).""" 

180 if not any(isinstance(f, _CompatFinder) for f in sys.meta_path): 

181 sys.meta_path.insert(0, _CompatFinder())