Re: PEP proposal: Per-Module Import Path

Eric Snow <[email protected]>
Newsgroups gmane.comp.python.import
Message-ID <CALFfu7CovCS_u_vs8TjGZ7X7R-5pUgbkkL8W0bkF4coN6ioKYQ@mail.gmail.com>
On Thu, Aug 1, 2013 at 6:56 PM, Nick Coghlan <[email protected]> wrote:

>
> On 2 Aug 2013 00:44, "Eric Snow" <[email protected]> wrote:
> > This is pretty much exactly what I've been thinking about since PyCon.
> The only difference is that I have a distinct ModuleSpec class and modules
> would get a new __spec__ attribute.
>
> And we can quit adding ever more magic attributes directly to the module
> namespace. I like it.
>
Yeah, that was part of what lead me to the idea.  This could be taken to
some pretty great lengths (I've given it a lot of thought), but I'm trying
hard to not do too much at once.  I wasn't even planning on pursuing
ModuleSpec until 3.5, much less any of my more drastic ideas.

> With that model, things might look vaguely like:
>
> 1. Finders would optionally offer "get_module_spec" (although a better
> name would be nice!)
>
How about "find_module"? <.5 wink>  Actually, I'm pretty sure this can be
done in a backward-compatible way (in not too much time I've roughed out an
implementation that should work).  I would rather not introduce more API to
the import system, but if that's preferable to hijacking (or improving
<wink>) find_module() then I can live with that.  However, given the crowd
that takes advantage of the import system APIs, I wouldn't consider the
change disruptive as long as it's backward compatible.

This would also allow us to deprecate PathEntryFinder.get_loader() which we
wouldn't have needed if we'd had something like ModuleSpec.

> 2. Specs would have a load() method for the import system to call that
> optionally accepted an existing module object (this would then cover
> reload).
>
That's been my plan from the get-go.  Good call on the reload case.

> 3. The responsibility for checking the sys.modules cache would move to the
> import system.
>
To me it makes sense to go even further.  ModuleSpec could easily take over
a bunch of the responsibilities of loaders, particularly related to the
management of the module objects.

Also, Loader.init_module_attrs() and importlib.util.module_to_load() could
be pulled before the 3.4 release (since they are new in 3.4).  It would
stink if we found we no longer needed them after they get locked in by the
release.  Note, however, that they can co-exist with ModuleSpec just fine
so it's not as big a deal.

> 4. We'd create a "SpecLoader" to offer backwards compatibility in the old
> __loader__ attribute.
>
Interesting.  I had anticipated loaders still sticking around, still
exposed by module.__loader__ and filling most of their current role,
especially with regard to the optional PEP 302 APIs.  I suppose we could
deprecate the __loader__ attribute, and maybe even __package__, in favor of
__spec__, but I don't think there's any rush to do so before Python 4000.

> Slight(!) tangent from the original problem, but a worthwhile refactoring
> issue to tackle, I think :)
>
Yeah, even if it proves too big a change for 3.4 and we take some other
approach for indirections, I think there's a lot to gain from separating
the module specification from the module and from the loader.  I've
attached a patch that does the bare minimum of what I think we'd want from
ModuleSpec.  I'll probably flesh out more of my ideas for it later.

Of course, I don't want anything here to get in the way of the .ref PEP
which I think has more concrete value.  So if this tangent threatens any
chance at getting indirection files for 3.4, I'd rather defer any effort on
these extras until 3.5 in favor of a simpler (if less desirable) approach.

-eric

_______________________________________________
Import-SIG mailing list
[email protected]
http://mail.python.org/mailman/listinfo/import-sig
modulespec.diff (application/octet-stream, 9.6 KB)
# HG changeset patch
# Parent db9fe49069edccee480196978e670968e271d2e8
Add ModuleSpec.

diff --git a/Lib/importlib/_bootstrap.py b/Lib/importlib/_bootstrap.py
--- a/Lib/importlib/_bootstrap.py
+++ b/Lib/importlib/_bootstrap.py
@@ -1476,6 +1476,203 @@
         return 'FileFinder({!r})'.format(self.path)
 
 
+# Module specifications #######################################################
+
+class ModuleSpec:
+    # XXX Factor out filename/cached/path into separate PathModuleSpec class?
+
+    @classmethod
+    def from_module(cls, module):
+        if isinstance(module, str):
+            module = sys.modules[module]
+        return cls(module.__name__,
+                   module.__loader__,
+                   module.__package__,
+                   getattr(module, '__file__', None),
+                   getattr(module, '__cached__', None),
+                   getattr(module, '__path__', None))
+
+    def __init__(self, name, loader=None, package=None, filename=None,
+                 cached=None, path=None):
+        self.name = name
+        self.loader = loader
+        self._package = package
+        self._filename = filename
+        self.cached = cached
+        self.path = path
+
+        # XXX Add 'paths' list containing chain of traversed paths?
+
+    def __repr__(self):
+        attrs = ', '.join('{}={!r}'.format(name, getattr(self, name))
+                          for name in ['name', 'loader', 'package',
+                                       'filename', 'cached', 'path']
+                          if getattr(self, name) is not None)
+        return '{}({})'.format(self.__class__.__name__, attrs)
+
+    @property
+    def package(self):
+        if self._package is not None:
+            return self._package
+        elif self.is_package:
+            return self.name
+        else:
+            return self.name.rpartition('.')[0]
+
+    @package.setter
+    def package(self, package):
+        self._package = package
+
+    @property
+    def filename(self):
+        if self._filename is None:
+            try:
+                get_filename = self.loader.get_filename
+            except AttributeError:
+                pass
+            else:
+                self._filename = get_filename(self.name)
+        return self._filename
+
+    @filename.setter
+    def filename(self, filename):
+        self._filename = filename
+
+    # XXX Comflicts with backward-compatibility.
+    #@property
+    #def is_package(self):
+    #    try:
+    #        is_package = self.loader.is_package
+    #    except AttributeError:
+    #        return self.path is not None
+    #    else:
+    #        return is_package(self.name)
+
+    def copy(self):
+        # XXX Should SimpleNamespace get this method?
+        # XXX Allow **kwargs for overrides?
+        copied = self.__class__(self.name)
+        copied.__original__ = self
+        vars(copied).update(vars(self))
+        return copied
+
+    def load(self):
+        # XXX Add 'module' param to support reloading.
+        # XXX Add 'match_attrs' param?  spec attrs would match module
+        # attrs (if set).
+        # XXX Let load() optionally add modules to sys.modules, rather
+        # than the loader.
+
+        if self.name not in sys.modules:
+            # ignore the returned module due to backward compatibility
+            self.loader.load_module(self.name)
+            _verbose_message('import {!r} # {!r}', self.name, self.loader)
+        # Backwards-compatibility; be nicer to skip the dict lookup.
+        module = sys.modules[self.name]
+        copied_spec = self.copy()
+        module.__spec__ = copied_spec
+
+#        # set required attributes, if not already set
+#        for attr in ['name', 'package', 'loader']:
+#            spec_attr = attr
+#            module_attr = "__{}__".format(attr)
+#            current = getattr(module, module_attr, None)
+#            if current is None:
+#                spec_value = getattr(self, spec_attr)
+#                try:
+#                    setattr(module, module_attr, spec_value)
+#                except AttributeError:
+#                    pass
+#            else:
+#                setattr(copied_spec, module_attr, current)
+#
+#        # set optional attributes, if not already set
+#        for attr in ['file', 'cached', 'path']:
+#            spec_attr = attr if attr != 'file' else 'filename'
+#            module_attr = "__{}__".format(attr)
+#            current = getattr(module, module_attr, None)
+#            if current is None:
+#                spec_value = getattr(self, spec_attr)
+#                if spec_value is not None:
+#                    try:
+#                        setattr(module, module_attr, spec_value)
+#                    except AttributeError:
+#                        pass
+#            else:
+#                setattr(spec, module_attr, current)
+
+
+        # Set the required attributes, if not already set.
+
+        if getattr(module, '__package__', None) is None:
+            try:
+                module.__package__ = self.package
+            except AttributeError:
+                pass
+        elif module.__package__ != self.package:
+            copied_spec.package = module.__package__
+
+        if getattr(module, '__loader__', None) is None:
+            try:
+                module.__loader__ = self.loader
+            except AttributeError:
+                pass
+        elif module.__loader__ != self.loader:
+            copied_spec.loader = module.__loader__
+
+        # Set the optional module attributes.
+
+        if getattr(module, '__file__', None) is None:
+            if self.filename is not None:
+                try:
+                    module.__file__ = self.filename
+                except AttributeError:
+                    pass
+        elif module.__file__ != self.filename:
+            copied_spec.filename = module.__file__
+
+        if getattr(module, '__cached__', None) is None:
+            if self.cached is not None:
+                try:
+                    module.__cached__ = self.cached
+                except AttributeError:
+                    pass
+        elif module.__cached__ != self.cached:
+            copied_spec.cached = module.__cached__
+
+        if getattr(module, '__path__', None) is None:
+            if self.path is not None:
+                try:
+                    module.__path__ = self.filename
+                except AttributeError:
+                    pass
+        elif module.__path__ is not None:
+            copied_spec.path = module.__path__
+
+        return module
+
+    def get_data(self, path):
+        return self.loader.get_data(path)
+
+    def set_data(self, path, data):
+        return self.loader.set_data(path, data)
+
+    # XXX Comflicts with backward-compatibility.
+    #def get_source(self):
+    #    return self.loader.get_code(self.name)
+
+    # XXX Comflicts with backward-compatibility.
+    #def get_code(self):
+    #    return self.loader.get_code(self.name)
+
+
+class _BackwardCompatibleModuleSpec(ModuleSpec):
+    # Proxies its loader.
+
+    def __getattr__(self, name):
+        return getattr(self.loader, name)
+
+
 # Import itself ###############################################################
 
 class _ImportLockContext:
@@ -1506,13 +1703,16 @@
         _warnings.warn('sys.meta_path is empty', ImportWarning)
     for finder in sys.meta_path:
         with _ImportLockContext():
-            loader = finder.find_module(name, path)
-        if loader is not None:
+            spec = finder.find_module(name, path)
+        if spec is not None:
             # The parent import may have already imported this module.
             if name not in sys.modules:
-                return loader
+                if not isinstance(spec, ModuleSpec):
+                    loader = spec
+                    spec = _BackwardCompatibleModuleSpec(name, loader, path)
+                return spec
             else:
-                return sys.modules[name].__loader__
+                return sys.modules[name].__spec__
     else:
         return None
 
@@ -1553,33 +1753,15 @@
         except AttributeError:
             msg = (_ERR_MSG + '; {} is not a package').format(name, parent)
             raise ImportError(msg, name=name)
-    loader = _find_module(name, path)
-    if loader is None:
+    spec = _find_module(name, path)
+    if spec is None:
         raise ImportError(_ERR_MSG.format(name), name=name)
-    elif name not in sys.modules:
-        # The parent import may have already imported this module.
-        loader.load_module(name)
-        _verbose_message('import {!r} # {!r}', name, loader)
-    # Backwards-compatibility; be nicer to skip the dict lookup.
-    module = sys.modules[name]
+    else:
+        module = spec.load()
     if parent:
         # Set the module as an attribute on its parent.
         parent_module = sys.modules[parent]
         setattr(parent_module, name.rpartition('.')[2], module)
-    # Set __package__ if the loader did not.
-    if getattr(module, '__package__', None) is None:
-        try:
-            module.__package__ = module.__name__
-            if not hasattr(module, '__path__'):
-                module.__package__ = module.__package__.rpartition('.')[0]
-        except AttributeError:
-            pass
-    # Set loader if need be.
-    if getattr(module, '__loader__', None) is None:
-        try:
-            module.__loader__ = loader
-        except AttributeError:
-            pass
     return module
 
 
@@ -1789,6 +1971,8 @@
         if '_d.pyd' in EXTENSION_SUFFIXES:
             WindowsRegistryFinder.DEBUG_BUILD = True
 
+    # XXX where should __spec__ be set for builtin/frozen modules?
+
 
 def _install(sys_module, _imp_module):
     """Install importlib as the implementation of import."""
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.