Coverage for gws-app/gws/lib/dynimport/__init__.py: 89%

54 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Dynamic imports of Python code. 

2 

3This package loads Python code at run time, either as a plain script or as a regular module: 

4 

5- ``load_file`` and ``load_string`` execute Python source and return its global namespace. 

6 Nothing is added to ``sys.modules``. This is used, for example, for Python config files. 

7- ``import_from_path`` imports a module from a file path and registers it in ``sys.modules``. 

8 Paths inside the application directory are imported as part of the ``gws`` package. 

9 Other paths (plugins) are imported relative to the deepest parent directory that is not a package 

10 (has no ``__init__.py``), so that the plugin's own package structure is preserved. 

11 

12Example:: 

13 

14 import gws.lib.dynimport 

15 

16 fn = gws.lib.dynimport.load_file('/data/config.py').get('main') 

17 

18 mod = gws.lib.dynimport.import_from_path('gws/plugin/ows_client/wms/caps.py') 

19""" 

20 

21import sys 

22import os 

23import importlib 

24 

25import gws 

26 

27 

28class Error(gws.Error): 

29 """Dynamic import error.""" 

30 pass 

31 

32 

33def load_file(path: str) -> dict: 

34 """Execute a Python file and return its globals. 

35 

36 Args: 

37 path: Path to the Python file. 

38 

39 Returns: 

40 The global namespace of the executed code. 

41 """ 

42 

43 return load_string(gws.u.read_file(path), path) 

44 

45 

46def load_string(text: str, path='') -> dict: 

47 """Execute a string as Python code and return its globals. 

48 

49 Args: 

50 text: Python source code. 

51 path: File path, used for ``__file__`` and in error messages. 

52 

53 Returns: 

54 The global namespace of the executed code. 

55 """ 

56 

57 globs = {'__file__': path} 

58 code = compile(text, path, 'exec') 

59 exec(code, globs) 

60 return globs 

61 

62 

63def import_from_path(path: str, base_dir: str = gws.c.APP_DIR): 

64 """Import a module from a file path. 

65 

66 If the path is a directory, its ``__init__.py`` is imported. If a module with the same name 

67 is already imported from the same file, it is returned as is. 

68 

69 Args: 

70 path: Relative or absolute path to the module file or package directory. 

71 base_dir: Base directory to resolve relative paths, the application directory by default. 

72 

73 Returns: 

74 The imported module. 

75 

76 Raises: 

77 ``Error``: If the module file is not found, a base directory cannot be located, 

78 a module with the same name was imported from a different file, or the import fails. 

79 """ 

80 abs_path = _abs_path(path, base_dir) 

81 if not os.path.isfile(abs_path): 

82 raise Error(f'{abs_path!r}: not found') 

83 

84 if abs_path.startswith(base_dir): 

85 # Our own module, import relatively to base_dir 

86 return _do_import(abs_path, base_dir) 

87 

88 # Plugin module, import relative to the bottom-most "namespace" dir (without __init__) 

89 dirs = abs_path.strip('/').split('/') 

90 dirs.pop() 

91 

92 for n in range(len(dirs), 0, -1): 

93 ns_dir = '/' + '/'.join(dirs[:n]) 

94 if not os.path.isfile(ns_dir + '/__init__.py'): 

95 return _do_import(abs_path, ns_dir) 

96 

97 raise Error(f'{abs_path!r}: cannot locate a base directory') 

98 

99 

100def _abs_path(path: str, base_dir: str) -> str: 

101 """Convert a path to an absolute normalized path of a Python file.""" 

102 if not os.path.isabs(path): 

103 path = os.path.join(base_dir, path) 

104 path = os.path.normpath(path) 

105 if os.path.isdir(path): 

106 path += '/__init__.py' 

107 return path 

108 

109 

110def _do_import(abs_path: str, base_dir: str): 

111 """Import a module by its absolute path, with the module name relative to the base directory.""" 

112 mod_name = _module_name(abs_path[len(base_dir):]) 

113 

114 if mod_name in sys.modules: 

115 mpath = getattr(sys.modules[mod_name], '__file__', None) 

116 if mpath != abs_path: 

117 raise Error(f'{abs_path!r}: overwriting {mod_name!r} from {mpath!r}') 

118 return sys.modules[mod_name] 

119 

120 gws.log.debug(f'import: {abs_path=} {mod_name=} {base_dir=}') 

121 

122 if base_dir not in sys.path: 

123 sys.path.insert(0, base_dir) 

124 

125 try: 

126 return importlib.import_module(mod_name) 

127 except Exception as exc: 

128 raise Error(f'{abs_path!r}: import failed') from exc 

129 

130 

131def _module_name(path: str) -> str: 

132 """Derive a dotted module name from a relative file path.""" 

133 parts = path.strip('/').split('/') 

134 if parts[-1] == '__init__.py': 

135 parts.pop() 

136 elif parts[-1].endswith('.py'): 

137 parts[-1] = parts[-1][:-3] 

138 return '.'.join(parts)