Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Python 3.5+: How to dynamically import a module given the full file path (in the presence of implicit sibling imports)?

Question

The standard library clearly documents how to import source files directly (given the absolute file path to the source file), but this approach does not work if that source file uses implicit sibling imports as described in the example below.

How could that example be adapted to work in the presence of implicit sibling imports?

I already checked out this and this other Stackoverflow questions on the topic, but they do not address implicit sibling imports within the file being imported by hand.

Setup/Example

Here's an illustrative example

Directory structure:

root/   - directory/     - app.py   - folder/     - implicit_sibling_import.py     - lib.py 

app.py:

import os import importlib.util  # construct absolute paths root = os.path.abspath(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) isi_path = os.path.join(root, 'folder', 'implicit_sibling_import.py')  def path_import(absolute_path):    '''implementation taken from https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly'''    spec = importlib.util.spec_from_file_location(absolute_path, absolute_path)    module = importlib.util.module_from_spec(spec)    spec.loader.exec_module(module)    return module  isi = path_import(isi_path) print(isi.hello_wrapper()) 

lib.py:

def hello():     return 'world' 

implicit_sibling_import.py:

import lib # this is the implicit sibling import. grabs root/folder/lib.py  def hello_wrapper():     return "ISI says: " + lib.hello()  #if __name__ == '__main__': #    print(hello_wrapper()) 

Running python folder/implicit_sibling_import.py with the if __name__ == '__main__': block commented out yields ISI says: world in Python 3.6.

But running python directory/app.py yields:

Traceback (most recent call last):   File "directory/app.py", line 10, in <module>     spec.loader.exec_module(module)   File "<frozen importlib._bootstrap_external>", line 678, in exec_module   File "<frozen importlib._bootstrap>", line 205, in _call_with_frames_removed   File "/Users/pedro/test/folder/implicit_sibling_import.py", line 1, in <module>     import lib ModuleNotFoundError: No module named 'lib' 

Workaround

If I add import sys; sys.path.insert(0, os.path.dirname(isi_path)) to app.py, python app.py yields world as intended, but I would like to avoid munging the sys.path if possible.

Answer requirements

I'd like python app.py to print ISI says: world and I'd like to accomplish this by modifying the path_import function.

I'm not sure of the implications of mangling sys.path. Eg. if there was directory/requests.py and I added the path to directory to the sys.path, I wouldn't want import requests to start importing directory/requests.py instead of importing the requests library that I installed with pip install requests.

The solution MUST be implemented as a python function that accepts the absolute file path to the desired module and returns the module object.

Ideally, the solution should not introduce side-effects (eg. if it does modify sys.path, it should return sys.path to its original state). If the solution does introduce side-effects, it should explain why a solution cannot be achieved without introducing side-effects.


PYTHONPATH

If I have multiple projects doing this, I don't want to have to remember to set PYTHONPATH every time I switch between them. The user should just be able to pip install my project and run it without any additional setup.

-m

The -m flag is the recommended/pythonic approach, but the standard library also clearly documents How to import source files directly. I'd like to know how I can adapt that approach to cope with implicit relative imports. Clearly, Python's internals must do this, so how do the internals differ from the "import source files directly" documentation?

like image 282
Pedro Cattori Avatar asked Jan 25 '17 20:01

Pedro Cattori


People also ask

How do I import a module dynamically?

To load dynamically a module call import(path) as a function with an argument indicating the specifier (aka path) to a module. const module = await import(path) returns a promise that resolves to an object containing the components of the imported module. } = await import(path);

How do you set a path to import in Python?

append() Function. This is the easiest way to import a Python module by adding the module path to the path variable. The path variable contains the directories Python interpreter looks in for finding modules that were imported in the source files.

How do I import an entire module?

You need to use the import keyword along with the desired module name. When interpreter comes across an import statement, it imports the module to your current program. You can use the functions inside a module by using a dot(.) operator along with the module name.


2 Answers

The easiest solution I could come up with is to temporarily modify sys.path in the function doing the import:

from contextlib import contextmanager  @contextmanager def add_to_path(p):     import sys     old_path = sys.path     sys.path = sys.path[:]     sys.path.insert(0, p)     try:         yield     finally:         sys.path = old_path  def path_import(absolute_path):    '''implementation taken from https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly'''    with add_to_path(os.path.dirname(absolute_path)):        spec = importlib.util.spec_from_file_location(absolute_path, absolute_path)        module = importlib.util.module_from_spec(spec)        spec.loader.exec_module(module)        return module 

This should not cause any problems unless you do imports in another thread concurrently. Otherwise, since sys.path is restored to its previous state, there should be no unwanted side effects.

Edit:

I realize that my answer is somewhat unsatisfactory but, digging into the code reveals that, the line spec.loader.exec_module(module) basically results in exec(spec.loader.get_code(module.__name__),module.__dict__) getting called. Here spec.loader.get_code(module.__name__) is simply the code contained in lib.py.

Thus a better answer to the question would have to find a way to make the import statement behave differently by simply injecting one or more global variables through the second argument of the exec-statement. However, "whatever you do to make the import machinery look in that file's folder, it'll have to linger beyond the duration of the initial import, since functions from that file might perform further imports when you call them", as stated by @user2357112 in the question comments.

Unfortunately the only way to change the behavior of the import statement seems to be to change sys.path or in a package __path__. module.__dict__ already contains __path__ so that doesn't seem to work which leaves sys.path (Or trying to figure out why exec does not treat the code as a package even though it has __path__ and __package__ ... - But I don't know where to start - Maybe it has something to do with having no __init__.py file).

Furthermore this issue does not seem to be specific to importlib but rather a general problem with sibling imports.

Edit2: If you don't want the module to end up in sys.modules the following should work (Note that any modules added to sys.modules during the import are removed):

from contextlib import contextmanager  @contextmanager def add_to_path(p):     import sys     old_path = sys.path     old_modules = sys.modules     sys.modules = old_modules.copy()     sys.path = sys.path[:]     sys.path.insert(0, p)     try:         yield     finally:         sys.path = old_path         sys.modules = old_modules 
like image 106
Jonathan von Schroeder Avatar answered Sep 26 '22 06:09

Jonathan von Schroeder


add to the PYTHONPATH environment variable the path your application is on

Augment the default search path for module files. The format is the same as the shell’s PATH: one or more directory pathnames separated by os.pathsep (e.g. colons on Unix or semicolons on Windows). Non-existent directories are silently ignored.

on bash its like this:

export PYTHONPATH="./folder/:${PYTHONPATH}" 

or run directly:

PYTHONPATH="./folder/:${PYTHONPATH}" python directory/app.py 
like image 26
ShmulikA Avatar answered Sep 22 '22 06:09

ShmulikA