Skip to content

Commit

Permalink
Merge pull request #331 from nucleic/import_hook
Browse files Browse the repository at this point in the history
Update import hook logic for Python 3
  • Loading branch information
MatthieuDartiailh committed Jan 2, 2019
2 parents 21d6bf8 + 7b411e7 commit 9d9fd88
Show file tree
Hide file tree
Showing 3 changed files with 260 additions and 26 deletions.
110 changes: 85 additions & 25 deletions enaml/core/import_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
#
# The full license is in the file COPYING.txt, distributed with this software.
#------------------------------------------------------------------------------
import imp
import marshal
import os
import io
Expand All @@ -16,6 +15,11 @@
from collections import defaultdict, namedtuple
from zipfile import ZipFile

if sys.version_info >= (3, 4):
from importlib.machinery import ModuleSpec
else:
# Fake ModuleSpec for re-using as much code as possible in Python 2
ModuleSpec = namedtuple('ModuleSpec', 'name, loader, origin')

from .enaml_compiler import EnamlCompiler, COMPILER_VERSION
from .parser import parse
Expand All @@ -25,16 +29,16 @@

# The magic number as symbols for the current Python interpreter. These
# define the naming scheme used when create cached files and directories.
MAGIC = imp.get_magic()
try:
MAGIC_TAG = 'enaml-py%s%s-cv%s' % (
sys.version_info.major, sys.version_info.minor, COMPILER_VERSION,
)
except AttributeError:
# Python 2.6 compatibility
MAGIC_TAG = 'enaml-py%s%s-cv%s' % (
sys.version_info[0], sys.version_info[1], COMPILER_VERSION,
)
import importlib
MAGIC = importlib.util.MAGIC_NUMBER
except (ImportError, AttributeError):
import imp
MAGIC = imp.get_magic()

MAGIC_TAG = 'enaml-py%s%s-cv%s' % (
sys.version_info.major, sys.version_info.minor, COMPILER_VERSION,
)
CACHEDIR = '__enamlcache__'


Expand Down Expand Up @@ -117,6 +121,8 @@ def find_module(cls, fullname, path=None):
""" Finds the given Enaml module and returns an importer, or
None if the module is not found.
Only used in Python 2.
"""
loader = cls.locate_module(fullname, path)
if loader is not None:
Expand All @@ -125,36 +131,90 @@ def find_module(cls, fullname, path=None):
raise ImportError(msg % loader)
return loader

@classmethod
def find_spec(cls, fullname, path=None, target=None):
""" Finds the given Enaml module and returns an importer, or
None if the module is not found.
This method is used only in Python 3.4+
"""
loader = cls.locate_module(fullname, path)
if loader is not None:
if not isinstance(loader, AbstractEnamlImporter):
msg = 'Enaml imports received invalid loader object %s'
raise ImportError(msg % loader)

spec = ModuleSpec(fullname, loader,
origin=loader.file_info.src_path)

return spec

#--------------------------------------------------------------------------
# Python Import Loader API
#--------------------------------------------------------------------------

def load_module(self, fullname):
""" Loads and returns the Python module for the given enaml path.
If a module already exisist in sys.path, the existing module is
reused, otherwise a new one is created.
Only used in Python 2.
"""
code, path = self.get_code()
if fullname in sys.modules:
pre_exists = True
mod = sys.modules[fullname]
else:
pre_exists = False
mod = sys.modules[fullname] = types.ModuleType(fullname)
mod.__loader__ = self
mod.__file__ = path
# Even though the import hook is already installed, this is a
# safety net to avoid potentially hard to find bugs if code has
# manually installed and removed a hook. The contract here is
# that the import hooks are always installed when executing the
# module code of an Enaml file.

mod = self.create_module(ModuleSpec(fullname, self,
origin=self.file_info.src_path))

code, _ = self.get_code()

try:
with imports():
exec_(code, mod.__dict__)
self.exec_module(mod, code)
except Exception:
if not pre_exists:
del sys.modules[fullname]
raise

return mod

def create_module(self, spec):
""" Create the Python module for the given enaml path.
If a module already exisist in sys.path, the existing module is
reused, otherwise a new one is created.
"""
fullname = spec.name
if fullname in sys.modules:
mod = sys.modules[fullname]
else:
mod = sys.modules[fullname] = types.ModuleType(fullname)

# XXX when dropping Python < 3.5 we can use module_from_spec
mod.__loader__ = self
mod.__file__ = self.file_info.src_path
mod.__name__ = fullname

return mod

def exec_module(self, module, code=None):
""" Execute the module in its own namespace.
"""
if code is None:
code, _ = self.get_code()

# Even though the import hook is already installed, this is a
# safety net to avoid potentially hard to find bugs if code has
# manually installed and removed a hook. The contract here is
# that the import hooks are always installed when executing the
# module code of an Enaml file.
with imports():
exec_(code, module.__dict__)

#--------------------------------------------------------------------------
# Abstract API
#--------------------------------------------------------------------------
Expand Down Expand Up @@ -563,9 +623,9 @@ def read_source(self):
return src

def _write_cache(self, code, ts, file_info):
""" Overridden to because cache files cannot be written into
the archive.
""" Overridden to because cache files cannot be written into
the archive.
"""
pass

Expand Down
174 changes: 174 additions & 0 deletions tests/core/test_importer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
#------------------------------------------------------------------------------
# Copyright (c) 2018, Nucleic Development Team.
#
# Distributed under the terms of the Modified BSD License.
#
# The full license is in the file COPYING.txt, distributed with this software.
#------------------------------------------------------------------------------
import importlib
import os
import sys
import time

import pytest

from enaml.core.import_hooks import AbstractEnamlImporter, imports


# Test handling wrong loader type
class WrongEnamlImporter(AbstractEnamlImporter):

@classmethod
def locate_module(cls, fullname, path=None):
return object()

def get_code(self):
pass


@pytest.mark.parametrize('method', ('find_module', 'find_spec'))
def test_handling_wrong_locate_module_implementation(method):
"""Test handling a poorly implemented locate_module method.
"""
loader = WrongEnamlImporter()
with pytest.raises(ImportError):
getattr(loader, method)('module_name')


SOURCE =\
"""
from enaml.widgets.api import *
enamldef Main(Window):
Field: fd:
name = 'content'
"""


@pytest.yield_fixture()
def enaml_module(tmpdir):
"""Create an enaml module in a tempdir and add it to sys.path.
"""
name = '__enaml_test_module__'
folder = str(tmpdir)
path = os.path.join(folder, name + '.enaml')
with open(path, 'w') as f:
f.write(SOURCE)
sys.path.append(folder)

yield name, folder, path

sys.path.remove(folder)
if name in sys.modules:
del sys.modules[name]


def test_import_and_cache_generation(enaml_module):
"""Test importing a module and checking that the cache was generated.
"""
name, folder, _ = enaml_module
with imports():
importlib.import_module(name)

assert name in sys.modules

cache_folder = os.path.join(folder, '__enamlcache__')
assert os.path.isdir(cache_folder)
cache_name = os.listdir(cache_folder)[0]
assert name in cache_name
assert '.enamlc' in cache_name


def test_import_when_cache_exists(enaml_module):
"""Test importing a module when the cache exists.
"""
name, folder, _ = enaml_module
assert name not in sys.modules
with imports():
importlib.import_module(name)

assert name in sys.modules
del sys.modules[name]

cache_folder = os.path.join(folder, '__enamlcache__')
assert os.path.isdir(cache_folder)
cache_name = os.listdir(cache_folder)[0]
cache_path = os.path.join(cache_folder, cache_name)
cache_time = os.path.getmtime(cache_path)

with imports():
importlib.import_module(name)

assert os.path.getmtime(cache_path) == cache_time
assert name in sys.modules


def test_import_cache_only(enaml_module):
"""Test importing a module for which we have no sources.
"""
name, _, path = enaml_module
with imports():
importlib.import_module(name)

assert name in sys.modules
del sys.modules[name]
os.remove(path)

with imports():
importlib.import_module(name)

assert name in sys.modules


def test_handling_importing_a_bugged_module(enaml_module):
"""Test that when importing a bugged module it does not stay in sys.modules
"""
name, _, path = enaml_module
with open(path, 'a') as f:
f.write('\nraise RuntimeError()')

with imports():
with pytest.raises(RuntimeError):
importlib.import_module(name)

assert name not in sys.modules


@pytest.yield_fixture
def enaml_importer():
"""Standard enaml importer whose state is restored after testing.
"""
print(imports, dir(imports))
old = imports.get_importers()

yield imports

imports._imports__importers = old


def test_importer_management(enaml_importer):
"""Test managing manually enaml importers.
"""
standard_importers_numbers = len(enaml_importer.get_importers())
enaml_importer.add_importer(WrongEnamlImporter)
assert WrongEnamlImporter in enaml_importer.get_importers()
enaml_importer.add_importer(WrongEnamlImporter)
assert (len(enaml_importer.get_importers()) ==
standard_importers_numbers + 1)
enaml_importer.remove_importer(WrongEnamlImporter)

# Test removing twice
enaml_importer.remove_importer(WrongEnamlImporter)

with pytest.raises(TypeError):
enaml_importer.add_importer(object)
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def generate_cache(path):
def make_library(lib):
# Create a library.zip with the examples.

root = os.path.normpath(os.path.join(os.path.dirname(__file__), '..',
root = os.path.normpath(os.path.join(os.path.dirname(__file__), '..', '..',
'examples', 'widgets'))

with zipfile.ZipFile(lib, 'w') as zf:
Expand Down

0 comments on commit 9d9fd88

Please sign in to comment.