Skip to content

Commit f8d5abb

Browse files
chadrikemmatyping
authored andcommitted
Allow use of entry-points like strings in mypy.ini to register plugins (#5358)
Fixes #3916
1 parent 223d104 commit f8d5abb

File tree

4 files changed

+100
-23
lines changed

4 files changed

+100
-23
lines changed

mypy/build.py

+31-20
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,7 @@ def load_plugins(options: Options, errors: Errors) -> Plugin:
563563
Return a plugin that encapsulates all plugins chained together. Always
564564
at least include the default plugin (it's last in the chain).
565565
"""
566+
import importlib
566567

567568
default_plugin = DefaultPlugin(options) # type: Plugin
568569
if not options.config_file:
@@ -579,34 +580,44 @@ def plugin_error(message: str) -> None:
579580
custom_plugins = [] # type: List[Plugin]
580581
errors.set_file(options.config_file, None)
581582
for plugin_path in options.plugins:
582-
# Plugin paths are relative to the config file location.
583-
plugin_path = os.path.join(os.path.dirname(options.config_file), plugin_path)
584-
585-
if not os.path.isfile(plugin_path):
586-
plugin_error("Can't find plugin '{}'".format(plugin_path))
587-
plugin_dir = os.path.dirname(plugin_path)
588-
fnam = os.path.basename(plugin_path)
589-
if not fnam.endswith('.py'):
583+
func_name = 'plugin'
584+
plugin_dir = None # type: Optional[str]
585+
if ':' in os.path.basename(plugin_path):
586+
plugin_path, func_name = plugin_path.rsplit(':', 1)
587+
if plugin_path.endswith('.py'):
588+
# Plugin paths can be relative to the config file location.
589+
plugin_path = os.path.join(os.path.dirname(options.config_file), plugin_path)
590+
if not os.path.isfile(plugin_path):
591+
plugin_error("Can't find plugin '{}'".format(plugin_path))
592+
plugin_dir = os.path.dirname(plugin_path)
593+
fnam = os.path.basename(plugin_path)
594+
module_name = fnam[:-3]
595+
sys.path.insert(0, plugin_dir)
596+
elif re.search(r'[\\/]', plugin_path):
597+
fnam = os.path.basename(plugin_path)
590598
plugin_error("Plugin '{}' does not have a .py extension".format(fnam))
591-
module_name = fnam[:-3]
592-
import importlib
593-
sys.path.insert(0, plugin_dir)
599+
else:
600+
module_name = plugin_path
601+
594602
try:
595-
m = importlib.import_module(module_name)
603+
module = importlib.import_module(module_name)
596604
except Exception:
597-
print('Error importing plugin {}\n'.format(plugin_path))
598-
raise # Propagate to display traceback
605+
plugin_error("Error importing plugin '{}'".format(plugin_path))
599606
finally:
600-
assert sys.path[0] == plugin_dir
601-
del sys.path[0]
602-
if not hasattr(m, 'plugin'):
603-
plugin_error('Plugin \'{}\' does not define entry point function "plugin"'.format(
604-
plugin_path))
607+
if plugin_dir is not None:
608+
assert sys.path[0] == plugin_dir
609+
del sys.path[0]
610+
611+
if not hasattr(module, func_name):
612+
plugin_error('Plugin \'{}\' does not define entry point function "{}"'.format(
613+
plugin_path, func_name))
614+
605615
try:
606-
plugin_type = getattr(m, 'plugin')(__version__)
616+
plugin_type = getattr(module, func_name)(__version__)
607617
except Exception:
608618
print('Error calling the plugin(version) entry point of {}\n'.format(plugin_path))
609619
raise # Propagate to display traceback
620+
610621
if not isinstance(plugin_type, type):
611622
plugin_error(
612623
'Type object expected as the return value of "plugin"; got {!r} (in {})'.format(

mypy/test/testcheck.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from mypy import build
1010
from mypy.build import BuildSource, Graph, SearchPaths
11-
from mypy.test.config import test_temp_dir
11+
from mypy.test.config import test_temp_dir, test_data_prefix
1212
from mypy.test.data import DataDrivenTestCase, DataSuite, FileOperation, UpdateFile
1313
from mypy.test.helpers import (
1414
assert_string_arrays_equal, normalize_error_messages, assert_module_equivalence,
@@ -152,6 +152,9 @@ def run_case_once(self, testcase: DataDrivenTestCase,
152152
sources.append(BuildSource(program_path, module_name,
153153
None if incremental_step else program_text))
154154

155+
plugin_dir = os.path.join(test_data_prefix, 'plugins')
156+
sys.path.insert(0, plugin_dir)
157+
155158
res = None
156159
try:
157160
res = build.build(sources=sources,
@@ -160,6 +163,10 @@ def run_case_once(self, testcase: DataDrivenTestCase,
160163
a = res.errors
161164
except CompileError as e:
162165
a = e.messages
166+
finally:
167+
assert sys.path[0] == plugin_dir
168+
del sys.path[0]
169+
163170
a = normalize_error_messages(a)
164171

165172
# Make sure error messages match

test-data/unit/check-custom-plugin.test

+47-2
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,22 @@
33
-- Note: Plugins used by tests live under test-data/unit/plugins. Defining
44
-- plugin files in test cases does not work reliably.
55

6-
[case testFunctionPlugin]
6+
[case testFunctionPluginFile]
77
# flags: --config-file tmp/mypy.ini
88
def f() -> str: ...
99
reveal_type(f()) # E: Revealed type is 'builtins.int'
1010
[file mypy.ini]
1111
[[mypy]
1212
plugins=<ROOT>/test-data/unit/plugins/fnplugin.py
1313

14+
[case testFunctionPlugin]
15+
# flags: --config-file tmp/mypy.ini
16+
def f() -> str: ...
17+
reveal_type(f()) # E: Revealed type is 'builtins.int'
18+
[file mypy.ini]
19+
[[mypy]
20+
plugins=fnplugin
21+
1422
[case testFunctionPluginFullnameIsNotNone]
1523
# flags: --config-file tmp/mypy.ini
1624
from typing import Callable, TypeVar
@@ -35,7 +43,19 @@ reveal_type(h()) # E: Revealed type is 'Any'
3543
plugins=<ROOT>/test-data/unit/plugins/fnplugin.py,
3644
<ROOT>/test-data/unit/plugins/plugin2.py
3745

38-
[case testMissingPlugin]
46+
[case testTwoPluginsMixedType]
47+
# flags: --config-file tmp/mypy.ini
48+
def f(): ...
49+
def g(): ...
50+
def h(): ...
51+
reveal_type(f()) # E: Revealed type is 'builtins.int'
52+
reveal_type(g()) # E: Revealed type is 'builtins.str'
53+
reveal_type(h()) # E: Revealed type is 'Any'
54+
[file mypy.ini]
55+
[[mypy]
56+
plugins=<ROOT>/test-data/unit/plugins/fnplugin.py, plugin2
57+
58+
[case testMissingPluginFile]
3959
# flags: --config-file tmp/mypy.ini
4060
[file mypy.ini]
4161
[[mypy]
@@ -44,6 +64,15 @@ plugins=missing.py
4464
tmp/mypy.ini:2: error: Can't find plugin 'tmp/missing.py'
4565
--' (work around syntax highlighting)
4666

67+
[case testMissingPlugin]
68+
# flags: --config-file tmp/mypy.ini
69+
[file mypy.ini]
70+
[[mypy]
71+
plugins=missing
72+
[out]
73+
tmp/mypy.ini:2: error: Error importing plugin 'missing'
74+
--' (work around syntax highlighting)
75+
4776
[case testMultipleSectionsDefinePlugin]
4877
# flags: --config-file tmp/mypy.ini
4978
[file mypy.ini]
@@ -74,6 +103,22 @@ tmp/mypy.ini:2: error: Plugin 'badext.pyi' does not have a .py extension
74103
[out]
75104
tmp/mypy.ini:2: error: Plugin '<ROOT>/test-data/unit/plugins/noentry.py' does not define entry point function "plugin"
76105

106+
[case testCustomPluginEntryPointFile]
107+
# flags: --config-file tmp/mypy.ini
108+
def f() -> str: ...
109+
reveal_type(f()) # E: Revealed type is 'builtins.int'
110+
[file mypy.ini]
111+
[[mypy]
112+
plugins=<ROOT>/test-data/unit/plugins/customentry.py:register
113+
114+
[case testCustomPluginEntryPoint]
115+
# flags: --config-file tmp/mypy.ini
116+
def f() -> str: ...
117+
reveal_type(f()) # E: Revealed type is 'builtins.int'
118+
[file mypy.ini]
119+
[[mypy]
120+
plugins=customentry:register
121+
77122
[case testInvalidPluginEntryPointReturnValue]
78123
# flags: --config-file tmp/mypy.ini
79124
def f(): pass

test-data/unit/plugins/customentry.py

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from mypy.plugin import Plugin
2+
3+
class MyPlugin(Plugin):
4+
def get_function_hook(self, fullname):
5+
if fullname == '__main__.f':
6+
return my_hook
7+
assert fullname is not None
8+
return None
9+
10+
def my_hook(ctx):
11+
return ctx.api.named_generic_type('builtins.int', [])
12+
13+
def register(version):
14+
return MyPlugin

0 commit comments

Comments
 (0)