Skip to content

Commit ced67d2

Browse files
committed
Util: For loading code at runtime, use importlib instead of imp
The `imp` module is deprecated and got removed from Python 3.12.
1 parent 40d4496 commit ced67d2

File tree

2 files changed

+86
-40
lines changed

2 files changed

+86
-40
lines changed

mqttwarn/util.py

Lines changed: 50 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# (c) 2014-2023 The mqttwarn developers
22
import functools
3-
import hashlib
4-
import imp
3+
import importlib.machinery
4+
import importlib.util
55
import json
66
import logging
77
import os
@@ -15,6 +15,9 @@
1515
import pkg_resources
1616
from six import string_types
1717

18+
if t.TYPE_CHECKING:
19+
from importlib._bootstrap_external import FileLoader
20+
1821
logger = logging.getLogger(__name__)
1922

2023

@@ -132,15 +135,20 @@ def load_module_from_file(path: str) -> types.ModuleType:
132135
:param path:
133136
:return:
134137
"""
135-
try:
136-
fp = open(path, "rb")
137-
digest = hashlib.md5(path.encode("utf-8")).hexdigest()
138-
return imp.load_source(digest, path, fp) # type: ignore[arg-type]
139-
finally:
140-
try:
141-
fp.close()
142-
except:
143-
pass
138+
name = Path(path).stem
139+
loader: "FileLoader"
140+
if path.endswith(".py"):
141+
loader = importlib.machinery.SourceFileLoader(fullname=name, path=path)
142+
elif path.endswith(".pyc"):
143+
loader = importlib.machinery.SourcelessFileLoader(fullname=name, path=path)
144+
else:
145+
raise ImportError(f"Loading file failed (only .py and .pyc): {path}")
146+
spec = importlib.util.spec_from_loader(loader.name, loader)
147+
if spec is None:
148+
raise ModuleNotFoundError(f"Failed loading module from file: {path}")
149+
mod = importlib.util.module_from_spec(spec)
150+
loader.exec_module(mod)
151+
return mod
144152

145153

146154
def load_module_by_name(name: str) -> types.ModuleType:
@@ -150,11 +158,11 @@ def load_module_by_name(name: str) -> types.ModuleType:
150158
:param name:
151159
:return:
152160
"""
153-
module = import_module(name)
161+
module = import_symbol(name)
154162
return module
155163

156164

157-
def import_module(name: str, path: t.Optional[t.List[str]] = None) -> types.ModuleType:
165+
def import_symbol(name: str, parent: t.Optional[types.ModuleType] = None) -> types.ModuleType:
158166
"""
159167
Derived from `import_from_dotted_path`:
160168
https://chase-seibert.github.io/blog/2014/04/23/python-imp-examples.html
@@ -168,16 +176,38 @@ def import_module(name: str, path: t.Optional[t.List[str]] = None) -> types.Modu
168176
next_module = name
169177
remaining_names = None
170178

171-
fp, pathname, description = imp.find_module(next_module, path)
172-
module = imp.load_module(next_module, fp, pathname, description) # type: ignore[arg-type]
179+
parent_name = None
180+
next_module_real = next_module
181+
182+
if parent is not None:
183+
next_module_real = "." + next_module
184+
parent_name = parent.__name__
185+
try:
186+
spec = importlib.util.find_spec(next_module_real, parent_name)
187+
except (AttributeError, ModuleNotFoundError):
188+
module = parent
189+
if module is None or module.__loader__ is None:
190+
raise ImportError(f"Symbol not found: {name}")
191+
if hasattr(module, next_module):
192+
return getattr(module, next_module)
193+
else:
194+
raise ImportError(f"Symbol not found: {name}, module={module}")
195+
196+
if spec is None:
197+
msg = f"Symbol not found: {name}"
198+
if parent is not None:
199+
msg += f", module={parent.__name__}"
200+
raise ImportError(msg)
201+
module = importlib.util.module_from_spec(spec)
202+
203+
# Actually load the module.
204+
loader: FileLoader = module.__loader__
205+
loader.exec_module(module)
173206

174207
if remaining_names is None:
175208
return module
176209

177-
if hasattr(module, remaining_names):
178-
return getattr(module, remaining_names)
179-
else:
180-
return import_module(remaining_names, path=list(module.__path__))
210+
return import_symbol(remaining_names, parent=module)
181211

182212

183213
def load_functions(filepath: t.Optional[str] = None) -> t.Optional[types.ModuleType]:
@@ -188,17 +218,7 @@ def load_functions(filepath: t.Optional[str] = None) -> t.Optional[types.ModuleT
188218
if not os.path.isfile(filepath):
189219
raise IOError("'{}' not found".format(filepath))
190220

191-
mod_name, file_ext = os.path.splitext(os.path.split(filepath)[-1])
192-
193-
if file_ext.lower() == ".py":
194-
py_mod = imp.load_source(mod_name, filepath)
195-
196-
elif file_ext.lower() == ".pyc":
197-
py_mod = imp.load_compiled(mod_name, filepath)
198-
199-
else:
200-
raise ValueError("'{}' does not have the .py or .pyc extension".format(filepath))
201-
221+
py_mod = load_module_from_file(filepath)
202222
return py_mod
203223

204224

tests/test_util.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# -*- coding: utf-8 -*-
2-
# (c) 2018-2022 The mqttwarn developers
2+
# (c) 2018-2023 The mqttwarn developers
33
from __future__ import division
44

55
import py_compile
66
import re
77
import time
8+
import types
89
from builtins import str
910

1011
import pytest
@@ -13,7 +14,7 @@
1314
Formatter,
1415
asbool,
1516
get_resource_content,
16-
import_module,
17+
import_symbol,
1718
load_file,
1819
load_function,
1920
load_functions,
@@ -135,9 +136,9 @@ def test_load_functions():
135136
assert str(excinfo.value) == "'{}' not found".format("unknown.txt")
136137

137138
# Load functions file that is not a python file
138-
with pytest.raises(ValueError) as excinfo:
139+
with pytest.raises(ImportError) as excinfo:
139140
load_functions(filepath=configfile_full)
140-
assert str(excinfo.value) == "'{}' does not have the .py or .pyc extension".format(configfile_full)
141+
assert re.match(r"Loading file failed \(only .py and .pyc\): .+full.ini", str(excinfo.value))
141142

142143
# Load bad functions file
143144
with pytest.raises(Exception):
@@ -172,7 +173,7 @@ def test_load_function():
172173
with pytest.raises(AttributeError) as excinfo:
173174
load_function(name="unknown", py_mod=py_mod)
174175
assert re.match(
175-
"Function 'unknown' does not exist in '.*{}c?'".format(funcfile_good),
176+
"Function 'unknown' does not exist in '.+functions_good.py'",
176177
str(excinfo.value),
177178
)
178179

@@ -182,15 +183,40 @@ def test_get_resource_content():
182183
assert "[defaults]" in payload
183184

184185

185-
def test_import_module():
186+
def test_import_symbol_module_success():
186187
"""
187-
Proof that the `import_module` function works as intended.
188+
Proof that the `import_symbol` function works as intended.
188189
"""
189-
symbol = import_module("mqttwarn.services.log")
190-
assert symbol.__name__ == "log"
190+
symbol = import_symbol("mqttwarn.services.log")
191+
assert symbol.__name__ == "mqttwarn.services.log"
192+
assert isinstance(symbol, types.ModuleType)
193+
194+
195+
def test_import_symbol_module_fail():
196+
"""
197+
Proof that the `import_symbol` function works as intended.
198+
"""
199+
with pytest.raises(ImportError) as ex:
200+
import_symbol("foo.bar.baz")
201+
assert ex.match("Symbol not found: foo.bar.baz")
202+
191203

192-
symbol = import_module("mqttwarn.services.log.plugin")
204+
def test_import_symbol_function_success():
205+
"""
206+
Proof that the `import_symbol` function works as intended.
207+
"""
208+
symbol = import_symbol("mqttwarn.services.log.plugin")
193209
assert symbol.__name__ == "plugin"
210+
assert isinstance(symbol, types.FunctionType)
211+
212+
213+
def test_import_symbol_function_fail():
214+
"""
215+
Proof that the `import_symbol` function works as intended.
216+
"""
217+
with pytest.raises(ImportError) as ex:
218+
import_symbol("mqttwarn.services.log.foo.bar.baz")
219+
assert ex.match("Symbol not found: foo.bar.baz, module=<module 'mqttwarn.services.log' from")
194220

195221

196222
def test_load_file_success(tmp_path):

0 commit comments

Comments
 (0)