TLDR; I would like to get some feedback around whether my approach for deprecating submodules is sound or not. The approach is to wrap all the MetaPathFinder
s in a DeprecatingModuleFinder
so that for old module references they create a ModuleSpec
by modifying the new submodule’s ModuleSpec
. The modification of the ModuleSpec
involves specifying a DeprecatingModuleLoader
which ensures that the sys.modules
module cache contains the right ModuleSpec
for both the old and new module reference, maintaining thus a “mirror” of the module hierarchy.
The problem
Internally in our library we provide convenient decorators to deprecate functions, parameters, classes and module attributes with simple decorators. These decorators take care of sending the DeprecationWarning
s and provide backward compatible bridging to the new function/parameter/class/attribute when possible.
We want to introduce the same for modules.
Requirements
For example:
We want to rename old_parent.child1
to new_parent.child2
.
- User code should still execute while calling any of:
import old_parent.child1
from old_parent import child1
-
import old_parent; assert old_parent.child1
– this assumes thatold_parent/__init__.py
originally had importedchild1
as an attribute:import .child1
- send
DeprecationWarning
when the above usage is detected in user code
Pseudo code
This is the skeleton of the process:
- wrap the default Finders in sys.meta_path
def wrap(finder: Any) -> Any:
if not hasattr(finder, 'find_spec'):
return finder
return DeprecatedModuleFinder(finder, module_name, alias)
sys.meta_path = [wrap(finder) for finder in sys.meta_path]
- For non-deprecated modules the
DeprecatedModuleFinder
simply delegates to itsfinder
. For the deprecated modules, it reports a deprecation warning and returns the non-deprecated version of the spec with a loader that is wrapped inDeprecatedModuleLoader
# change back the name to the deprecated module name
spec.name = fullname
spec.loader = DeprecatedModuleLoader(spec.loader, fullname, new_fullname)
- The
DeprecatedModuleLoader
wraps the original loader’sexec_module
method to ensure that both the deprecated module reference and the new place of the module is insys.modules
, pointing to the sameModuleSpec
instance:
# check for new_module whether it was loaded
if self.new_module_name in sys.modules:
# found it - no need to load the module again
sys.modules[self.old_module_name] = sys.modules[self.new_module_name]
return
# now we know we have to initialize the module
sys.modules[self.old_module_name] = module
sys.modules[self.new_module_name] = module
try:
return method(module)
except Exception as ex:
# if there's an error, we atomically remove both
del sys.modules[self.new_module_name]
del sys.modules[self.old_module_name]
raise ex
- When attribute handling is requested, the module is set as a deprecated attribute on the old parent with the old child name.
I did implement this and things seem to be working quite well across 3.6-3.9 (and even for older Loaders that define load_module
). The only caveat I’ve seen for now is that this kind of “aliasing” is not resolved by PyCharm or other editors - which is kind of okay, as it incentivizes users to switch over to the new module structure.
Question 1: However, I’m not an importlib machinery expert and would love to get some feedback whether I’m doing something fundamentally wrong here, or if this is a sound approach.
Question 2: If this is a valid approach I wonder if extracting this to a separate open source library would be valuable for the community to help with module deprecations.