Add a plugin hook for specifying per-module configuration data (#7871)
This is useful in general for plugins that might have per-module
configuration and we will be able to use it to manage the interaction
between mypyc caches and mypy caches without needing to add custom
mypyc hooks.
diff --git a/docs/source/extending_mypy.rst b/docs/source/extending_mypy.rst
index b1d0f9d..340d62c 100644
--- a/docs/source/extending_mypy.rst
+++ b/docs/source/extending_mypy.rst
@@ -236,6 +236,14 @@
be used if a library has dependencies that are dynamically loaded
based on configuration information.
+**report_config_data()** can be used if the plugin has some sort of
+per-module configuration that can affect typechecking. In that case,
+when the configuration for a module changes, we want to invalidate
+mypy's cache for that module so that it can be rechecked. This hook
+should be used to report to mypy any relevant configuration data,
+so that mypy knows to recheck the module if the configuration changes.
+The hooks hould return data encodable as JSON.
+
Notes about the semantic analyzer
*********************************
diff --git a/mypy/build.py b/mypy/build.py
index 2bd1b65..574f625 100644
--- a/mypy/build.py
+++ b/mypy/build.py
@@ -269,6 +269,7 @@
('interface_hash', str), # hash representing the public interface
('version_id', str), # mypy version for cache invalidation
('ignore_all', bool), # if errors were ignored
+ ('plugin_data', Any), # config data from plugins
])
# NOTE: dependencies + suppressed == all reachable imports;
# suppressed contains those reachable imports that were prevented by
@@ -303,6 +304,7 @@
meta.get('interface_hash', ''),
meta.get('version_id', sentinel),
meta.get('ignore_all', True),
+ meta.get('plugin_data', None),
)
@@ -1162,6 +1164,13 @@
if manager.plugins_snapshot != manager.old_plugins_snapshot:
manager.log('Metadata abandoned for {}: plugins differ'.format(id))
return None
+ # So that plugins can return data with tuples in it without
+ # things silently always invalidating modules, we round-trip
+ # the config data. This isn't beautiful.
+ plugin_data = json.loads(json.dumps(manager.plugin.report_config_data(id, path)))
+ if m.plugin_data != plugin_data:
+ manager.log('Metadata abandoned for {}: plugin configuration differs'.format(id))
+ return None
manager.add_stats(fresh_metas=1)
return m
@@ -1284,6 +1293,7 @@
'interface_hash': meta.interface_hash,
'version_id': manager.version_id,
'ignore_all': meta.ignore_all,
+ 'plugin_data': meta.plugin_data,
}
if manager.options.debug_cache:
meta_str = json.dumps(meta_dict, indent=2, sort_keys=True)
@@ -1366,6 +1376,8 @@
data_str = json_dumps(data, manager.options.debug_cache)
interface_hash = compute_hash(data_str)
+ plugin_data = manager.plugin.report_config_data(id, path)
+
# Obtain and set up metadata
try:
st = manager.get_stat(path)
@@ -1429,6 +1441,7 @@
'interface_hash': interface_hash,
'version_id': manager.version_id,
'ignore_all': ignore_all,
+ 'plugin_data': plugin_data,
}
# Write meta cache file
diff --git a/mypy/interpreted_plugin.py b/mypy/interpreted_plugin.py
index ae83bc8..a2cabe7 100644
--- a/mypy/interpreted_plugin.py
+++ b/mypy/interpreted_plugin.py
@@ -41,6 +41,9 @@
assert self._modules is not None
return lookup_fully_qualified(fullname, self._modules)
+ def report_config_data(self, id: str, path: str) -> Any:
+ return None
+
def get_additional_deps(self, file: MypyFile) -> List[Tuple[int, str, int]]:
return []
diff --git a/mypy/plugin.py b/mypy/plugin.py
index 31bc335..03a1cce 100644
--- a/mypy/plugin.py
+++ b/mypy/plugin.py
@@ -459,6 +459,19 @@
assert self._modules is not None
return lookup_fully_qualified(fullname, self._modules)
+ def report_config_data(self, id: str, path: str) -> Any:
+ """Get representation of configuration data for a module.
+
+ The data must be encodable as JSON and will be stored in the
+ cache metadata for the module. A mismatch between the cached
+ values and the returned will result in that module's cache
+ being invalidated and the module being rechecked.
+
+ This can be used to incorporate external configuration information
+ that might require changes to typechecking.
+ """
+ return None
+
def get_additional_deps(self, file: MypyFile) -> List[Tuple[int, str, int]]:
"""Customize dependencies for a module.
@@ -666,6 +679,9 @@
def lookup_fully_qualified(self, fullname: str) -> Optional[SymbolTableNode]:
return self.plugin.lookup_fully_qualified(fullname)
+ def report_config_data(self, id: str, path: str) -> Any:
+ return self.plugin.report_config_data(id, path)
+
def get_additional_deps(self, file: MypyFile) -> List[Tuple[int, str, int]]:
return self.plugin.get_additional_deps(file)
@@ -734,6 +750,10 @@
for plugin in self._plugins:
plugin.set_modules(modules)
+ def report_config_data(self, id: str, path: str) -> Any:
+ config_data = [plugin.report_config_data(id, path) for plugin in self._plugins]
+ return config_data if any(x is not None for x in config_data) else None
+
def get_additional_deps(self, file: MypyFile) -> List[Tuple[int, str, int]]:
deps = []
for plugin in self._plugins:
diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test
index 2d3d353..d8fc776 100644
--- a/test-data/unit/check-incremental.test
+++ b/test-data/unit/check-incremental.test
@@ -4936,6 +4936,24 @@
[out2]
tmp/a.py:2: error: Incompatible types in assignment (expression has type "int", variable has type "str")
+[case testPluginConfigData]
+# flags: --config-file tmp/mypy.ini
+import a
+import b
+[file a.py]
+[file b.py]
+[file test.json]
+{"a": false, "b": false}
+[file test.json.2]
+{"a": true, "b": false}
+
+[file mypy.ini]
+\[mypy]
+plugins=<ROOT>/test-data/unit/plugins/config_data.py
+
+# The config change will force a to be rechecked but not b.
+[rechecked a]
+
[case testLiteralIncrementalTurningIntoLiteral]
import mod
reveal_type(mod.a)
diff --git a/test-data/unit/plugins/config_data.py b/test-data/unit/plugins/config_data.py
new file mode 100644
index 0000000..d24ce61
--- /dev/null
+++ b/test-data/unit/plugins/config_data.py
@@ -0,0 +1,18 @@
+import os
+import json
+
+from typing import Any
+
+from mypy.plugin import Plugin
+
+
+class ConfigDataPlugin(Plugin):
+ def report_config_data(self, id: str, path: str) -> Any:
+ path = os.path.join('tmp/test.json')
+ with open(path) as f:
+ data = json.load(f)
+ return data.get(id)
+
+
+def plugin(version):
+ return ConfigDataPlugin