New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Invalidate all cache files if plugins change #5878
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -21,6 +21,7 @@ | |
import sys | ||
import time | ||
import errno | ||
import types | ||
|
||
from typing import (AbstractSet, Any, Dict, Iterable, Iterator, List, | ||
Mapping, NamedTuple, Optional, Set, Tuple, Union, Callable) | ||
|
@@ -183,7 +184,7 @@ def _build(sources: List[BuildSource], | |
reports = Reports(data_dir, options.report_dirs) | ||
source_set = BuildSourceSet(sources) | ||
errors = Errors(options.show_error_context, options.show_column_numbers) | ||
plugin = load_plugins(options, errors) | ||
plugin, snapshot = load_plugins(options, errors) | ||
|
||
# Construct a build manager object to hold state during the build. | ||
# | ||
|
@@ -195,6 +196,7 @@ def _build(sources: List[BuildSource], | |
options=options, | ||
version_id=__version__, | ||
plugin=plugin, | ||
plugins_snapshot=snapshot, | ||
errors=errors, | ||
flush_errors=flush_errors, | ||
fscache=fscache) | ||
|
@@ -304,17 +306,20 @@ def import_priority(imp: ImportBase, toplevel_priority: int) -> int: | |
return toplevel_priority | ||
|
||
|
||
def load_plugins(options: Options, errors: Errors) -> Plugin: | ||
def load_plugins(options: Options, errors: Errors) -> Tuple[Plugin, Dict[str, str]]: | ||
"""Load all configured plugins. | ||
|
||
Return a plugin that encapsulates all plugins chained together. Always | ||
at least include the default plugin (it's last in the chain). | ||
The second return value is a snapshot of versions/hashes of loaded user | ||
plugins (for cache validation). | ||
""" | ||
import importlib | ||
snapshot = {} # type: Dict[str, str] | ||
|
||
default_plugin = DefaultPlugin(options) # type: Plugin | ||
if not options.config_file: | ||
return default_plugin | ||
return default_plugin, snapshot | ||
|
||
line = find_config_file_line_number(options.config_file, 'mypy', 'plugins') | ||
if line == -1: | ||
|
@@ -375,11 +380,27 @@ def plugin_error(message: str) -> None: | |
'(in {})'.format(plugin_path)) | ||
try: | ||
custom_plugins.append(plugin_type(options)) | ||
snapshot[module_name] = take_module_snapshot(module) | ||
except Exception: | ||
print('Error constructing plugin instance of {}\n'.format(plugin_type.__name__)) | ||
raise # Propagate to display traceback | ||
# Custom plugins take precedence over the default plugin. | ||
return ChainedPlugin(options, custom_plugins + [default_plugin]) | ||
return ChainedPlugin(options, custom_plugins + [default_plugin]), snapshot | ||
|
||
|
||
def take_module_snapshot(module: types.ModuleType) -> str: | ||
"""Take plugin module snapshot by recording its version and hash. | ||
|
||
We record _both_ hash and the version to detect more possible changes | ||
(e.g. if there is a change in modules imported by a plugin). | ||
""" | ||
if hasattr(module, '__file__'): | ||
with open(module.__file__, 'rb') as f: | ||
digest = hashlib.md5(f.read()).hexdigest() | ||
else: | ||
digest = 'unknown' | ||
ver = getattr(module, '__version__', 'none') | ||
return '{}:{}'.format(ver, digest) | ||
|
||
|
||
def find_config_file_line_number(path: str, section: str, setting_name: str) -> int: | ||
|
@@ -426,6 +447,11 @@ class BuildManager(BuildManagerBase): | |
stale_modules: Set of modules that needed to be rechecked (only used by tests) | ||
version_id: The current mypy version (based on commit id when possible) | ||
plugin: Active mypy plugin(s) | ||
plugins_snapshot: | ||
Snapshot of currently active user plugins (versions and hashes) | ||
old_plugins_snapshot: | ||
Plugins snapshot from previous incremental run (or None in | ||
non-incremental mode and if cache was not found) | ||
errors: Used for reporting all errors | ||
flush_errors: A function for processing errors after each SCC | ||
cache_enabled: Whether cache is being read. This is set based on options, | ||
|
@@ -446,6 +472,7 @@ def __init__(self, data_dir: str, | |
options: Options, | ||
version_id: str, | ||
plugin: Plugin, | ||
plugins_snapshot: Dict[str, str], | ||
errors: Errors, | ||
flush_errors: Callable[[List[str], bool], None], | ||
fscache: FileSystemCache, | ||
|
@@ -471,7 +498,6 @@ def __init__(self, data_dir: str, | |
self.indirection_detector = TypeIndirectionVisitor() | ||
self.stale_modules = set() # type: Set[str] | ||
self.rechecked_modules = set() # type: Set[str] | ||
self.plugin = plugin | ||
self.flush_errors = flush_errors | ||
self.cache_enabled = options.incremental and ( | ||
not options.fine_grained_incremental or options.use_fine_grained_cache) | ||
|
@@ -487,6 +513,9 @@ def __init__(self, data_dir: str, | |
in self.options.shadow_file} | ||
# a mapping from each file being typechecked to its possible shadow file | ||
self.shadow_equivalence_map = {} # type: Dict[str, Optional[str]] | ||
self.plugin = plugin | ||
self.plugins_snapshot = plugins_snapshot | ||
self.old_plugins_snapshot = read_plugins_snapshot(self) | ||
|
||
def use_fine_grained_cache(self) -> bool: | ||
return self.cache_enabled and self.options.use_fine_grained_cache | ||
|
@@ -685,6 +714,30 @@ def write_protocol_deps_cache(proto_deps: Dict[str, Set[str]], | |
blocker=True) | ||
|
||
|
||
def write_plugins_snapshot(manager: BuildManager) -> None: | ||
"""Write snapshot of versions and hashes of currently active plugins.""" | ||
name = os.path.join(_cache_dir_prefix(manager), '@plugins_snapshot.json') | ||
if not atomic_write(name, json.dumps(manager.plugins_snapshot), '\n'): | ||
manager.errors.set_file(_cache_dir_prefix(manager), None) | ||
manager.errors.report(0, 0, "Error writing plugins snapshot", | ||
blocker=True) | ||
|
||
|
||
def read_plugins_snapshot(manager: BuildManager) -> Optional[Dict[str, str]]: | ||
"""Read cached snapshot of versions and hashes of plugins from previous run.""" | ||
name = os.path.join(_cache_dir_prefix(manager), '@plugins_snapshot.json') | ||
snapshot = _load_json_file(name, manager, | ||
log_sucess='Plugins snapshot ', | ||
log_error='Could not load plugins snapshot: ') | ||
if snapshot is None: | ||
return None | ||
if not isinstance(snapshot, dict): | ||
manager.log('Could not load plugins snapshot: cache is not a dict: {}' | ||
.format(type(snapshot))) | ||
return None | ||
return snapshot | ||
|
||
|
||
def read_protocol_cache(manager: BuildManager, | ||
graph: Graph) -> Optional[Dict[str, Set[str]]]: | ||
"""Read and validate protocol dependencies cache. | ||
|
@@ -848,6 +901,11 @@ def find_cache_meta(id: str, path: str, manager: BuildManager) -> Optional[Cache | |
manager.trace(' {}: {} != {}' | ||
.format(key, cached_options.get(key), current_options.get(key))) | ||
return None | ||
if manager.old_plugins_snapshot and manager.plugins_snapshot: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about adding a test for this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can't yet add tests for daemon. I opened #5891 for this. |
||
# Check if plugins are still the same. | ||
if manager.plugins_snapshot != manager.old_plugins_snapshot: | ||
manager.log('Metadata abandoned for {}: plugins differ'.format(id)) | ||
return None | ||
|
||
manager.add_stats(fresh_metas=1) | ||
return m | ||
|
@@ -2170,6 +2228,9 @@ def dispatch(sources: List[BuildSource], manager: BuildManager) -> Graph: | |
process_fine_grained_cache_graph(graph, manager) | ||
else: | ||
process_graph(graph, manager) | ||
# Update plugins snapshot. | ||
write_plugins_snapshot(manager) | ||
manager.old_plugins_snapshot = manager.plugins_snapshot | ||
if manager.options.cache_fine_grained or manager.options.fine_grained_incremental: | ||
# If we are running a daemon or are going to write cache for further fine grained use, | ||
# then we need to collect fine grained protocol dependencies. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -24,6 +24,10 @@ | |
-- # flags2: --another-flag | ||
-- (and flags3 for the third run, and so on). | ||
-- | ||
-- Incremental tests involving plugins that get updated are also supported. | ||
-- All plugin files that are updated *must* end in '_plugin', so they will | ||
-- be unloaded from 'sys.modules' between incremental steps. | ||
-- | ||
-- Any files that we expect to be rechecked should be annotated in the [rechecked] | ||
-- annotation, and any files expect to be stale (aka have a modified interface) | ||
-- should be annotated in the [stale] annotation. Note that a file that ends up | ||
|
@@ -5389,3 +5393,109 @@ E = Enum('E', f()) # type: ignore | |
[builtins fixtures/list.pyi] | ||
[out] | ||
[out2] | ||
|
||
[case testChangedPluginsInvalidateCache] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe add another test where only the version changes (e.g. import version from another module so that the text of the plugin doesn't change). |
||
# flags: --config-file tmp/mypy.ini | ||
import a | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it make sense to add a fine-grained incremental test case where a plugin changes? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As we discussed, this will have no effect. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm, I have found there is another daemon command |
||
[file a.py] | ||
from b import x | ||
y: int = x | ||
|
||
[file a.py.2] | ||
from b import x | ||
y: int = x | ||
touch = 1 | ||
|
||
[file b.py] | ||
class C: ... | ||
def f() -> C: ... | ||
x = f() | ||
|
||
[file basic_plugin.py] | ||
from mypy.plugin import Plugin | ||
|
||
class MyPlugin(Plugin): | ||
def get_function_hook(self, fullname): | ||
if fullname.endswith('.f'): | ||
return my_hook | ||
assert fullname is not None | ||
return None | ||
|
||
def my_hook(ctx): | ||
return ctx.api.named_generic_type('builtins.int', []) | ||
|
||
def plugin(version): | ||
return MyPlugin | ||
|
||
[file basic_plugin.py.2] | ||
from mypy.plugin import Plugin | ||
|
||
class MyPlugin(Plugin): | ||
def get_function_hook(self, fullname): | ||
if fullname.endswith('.f'): | ||
return my_hook | ||
assert fullname is not None | ||
return None | ||
|
||
def my_hook(ctx): | ||
return ctx.api.named_generic_type('builtins.str', []) | ||
|
||
def plugin(version): | ||
return MyPlugin | ||
[file mypy.ini] | ||
[[mypy] | ||
plugins=basic_plugin.py | ||
[out] | ||
[out2] | ||
tmp/a.py:2: error: Incompatible types in assignment (expression has type "str", variable has type "int") | ||
|
||
[case testChangedPluginsInvalidateCache2] | ||
# flags: --config-file tmp/mypy.ini | ||
import a | ||
[file a.py] | ||
from b import x | ||
y: int = x | ||
|
||
[file a.py.2] | ||
from b import x | ||
y: int = x | ||
touch = 1 | ||
|
||
[file b.py] | ||
class C: ... | ||
def f() -> C: ... | ||
x = f() | ||
|
||
[file basic_plugin.py] | ||
from mypy.plugin import Plugin | ||
from version_plugin import __version__, choice | ||
|
||
class MyPlugin(Plugin): | ||
def get_function_hook(self, fullname): | ||
if fullname.endswith('.f'): | ||
return my_hook | ||
assert fullname is not None | ||
return None | ||
|
||
def my_hook(ctx): | ||
if choice: | ||
return ctx.api.named_generic_type('builtins.int', []) | ||
else: | ||
return ctx.api.named_generic_type('builtins.str', []) | ||
|
||
def plugin(version): | ||
return MyPlugin | ||
|
||
[file version_plugin.py] | ||
__version__ = 0.1 | ||
choice = True | ||
|
||
[file version_plugin.py.2] | ||
__version__ = 0.2 | ||
choice = False | ||
[file mypy.ini] | ||
[[mypy] | ||
plugins=basic_plugin.py | ||
[out] | ||
[out2] | ||
tmp/a.py:2: error: Incompatible types in assignment (expression has type "str", variable has type "int") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Document the new attributes in the class docstring.