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

54 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-16 23:09 +0200

1"""Handle dynamic imports""" 

2 

3import sys 

4import os 

5import importlib 

6 

7import gws 

8 

9 

10class Error(gws.Error): 

11 """Custom error class for import-related exceptions.""" 

12 pass 

13 

14 

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

16 """Load a python file and return its globals.""" 

17 

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

19 

20 

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

22 """Load a string as python code and return its globals.""" 

23 

24 globs = {'__file__': path} 

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

26 exec(code, globs) 

27 return globs 

28 

29 

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

31 """Imports a module from a given file path. 

32 

33 Args: 

34 path: The relative or absolute path to the module file. 

35 base_dir: The base directory to resolve relative paths. Defaults to `gws.c.APP_DIR`. 

36 

37 Returns: 

38 The imported module. 

39 

40 Raises: 

41 Error: If the module file is not found or a base directory cannot be located. 

42 """ 

43 abs_path = _abs_path(path, base_dir) 

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

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

46 

47 if abs_path.startswith(base_dir): 

48 # Our own module, import relatively to base_dir 

49 return _do_import(abs_path, base_dir) 

50 

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

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

53 dirs.pop() 

54 

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

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

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

58 return _do_import(abs_path, ns_dir) 

59 

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

61 

62 

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

64 """Converts a relative path to an absolute normalized path. 

65 

66 Args: 

67 path: The input file path. 

68 base_dir: The base directory for resolving relative paths. 

69 

70 Returns: 

71 The absolute, normalized file path. 

72 """ 

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

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

75 path = os.path.normpath(path) 

76 if os.path.isdir(path): 

77 path += '/__init__.py' 

78 return path 

79 

80 

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

82 """Imports a module given its absolute path and base directory. 

83 

84 Args: 

85 abs_path: The absolute path to the module file. 

86 base_dir: The base directory for resolving module names. 

87 

88 Returns: 

89 The imported module. 

90 

91 Raises: 

92 Error: If the module import fails or an existing module is being overwritten. 

93 """ 

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

95 

96 if mod_name in sys.modules: 

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

98 if mpath != abs_path: 

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

100 return sys.modules[mod_name] 

101 

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

103 

104 if base_dir not in sys.path: 

105 sys.path.insert(0, base_dir) 

106 

107 try: 

108 return importlib.import_module(mod_name) 

109 except Exception as exc: 

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

111 

112 

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

114 """Derives the module name from a given file path. 

115 

116 Args: 

117 path: The file path of the module. 

118 

119 Returns: 

120 The module name in dotted notation. 

121 """ 

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

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

124 parts.pop() 

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

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

127 return '.'.join(parts)