Skip to content
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

3.8.0: dictionary changed size during iteration iterating on importlib_metadata.distributions() #293

Closed
omry opened this issue Mar 27, 2021 · 12 comments

Comments

@omry
Copy link

omry commented Mar 27, 2021

3.8 is breaking my CI Link.

For now I will pin to 3.7.3, but you might want to investigate it.

Exception:

nox > [2021-03-27 02:20:07,146] Command pytest --nbval examples/jupyter_notebooks/compose_configs_in_notebook.ipynb failed with exit code 1:
Traceback (most recent call last):
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/bin/pytest", line 8, in <module>
    sys.exit(console_main())
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/lib/python3.6/site-packages/_pytest/config/__init__.py", line 185, in console_main
    code = main()
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/lib/python3.6/site-packages/_pytest/config/__init__.py", line 143, in main
    config = _prepareconfig(args, plugins)
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/lib/python3.6/site-packages/_pytest/config/__init__.py", line 319, in _prepareconfig
    pluginmanager=pluginmanager, args=args
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/lib/python3.6/site-packages/pluggy/hooks.py", line 286, in __call__
    return self._hookexec(self, self.get_hookimpls(), kwargs)
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/lib/python3.6/site-packages/pluggy/manager.py", line 93, in _hookexec
    return self._inner_hookexec(hook, methods, kwargs)
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/lib/python3.6/site-packages/pluggy/manager.py", line 87, in <lambda>
    firstresult=hook.spec.opts.get("firstresult") if hook.spec else False,
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/lib/python3.6/site-packages/pluggy/callers.py", line 203, in _multicall
    gen.send(outcome)
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/lib/python3.6/site-packages/_pytest/helpconfig.py", line 100, in pytest_cmdline_parse
    config: Config = outcome.get_result()
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/lib/python3.6/site-packages/pluggy/callers.py", line 80, in get_result
    raise ex[1].with_traceback(ex[2])
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/lib/python3.6/site-packages/pluggy/callers.py", line 187, in _multicall
    res = hook_impl.function(*args)
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/lib/python3.6/site-packages/_pytest/config/__init__.py", line 1003, in pytest_cmdline_parse
    self.parse(args)
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/lib/python3.6/site-packages/_pytest/config/__init__.py", line 1283, in parse
    self._preparse(args, addopts=addopts)
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/lib/python3.6/site-packages/_pytest/config/__init__.py", line 1172, in _preparse
    self.pluginmanager.load_setuptools_entrypoints("pytest11")
  File "/home/circleci/project/.nox/test_jupyter_notebooks-3-6/lib/python3.6/site-packages/pluggy/manager.py", line 289, in load_setuptools_entrypoints
    for dist in importlib_metadata.distributions():
RuntimeError: dictionary changed size during iteration
@m-melis
Copy link

m-melis commented Mar 27, 2021

Confirmed. All our CI pipeline unittests fail due to the new version: https://gitlab.com/secml/secml/-/pipelines/277431124

This is independent of the Python version.

@jaraco
Copy link
Member

jaraco commented Mar 27, 2021

Thanks for the report. I can't tell from the traceback what's happening, and the issue isn't happening in my CI environments that also rely on importlib_metadata 3.8 and pluggy. Moreover, when looking at #290, I don't see anything in particular where a dictionary size might be altered. There is this line, but the infos and eggs are mutated in the __init__ so shouldn't be iterated over before they're fully constructed.

Can someone help distill the issue into a reproducer I can use to troubleshoot?

@jaraco
Copy link
Member

jaraco commented Mar 27, 2021

I'm able to reproduce the issue by checking out secml and running tox -e py36.

@jaraco
Copy link
Member

jaraco commented Mar 27, 2021

If I disable the lru_cache around FastPath.lookup, the problem goes away:

diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py
index 11b9aac..1b95c8f 100644
--- a/importlib_metadata/__init__.py
+++ b/importlib_metadata/__init__.py
@@ -648,7 +648,6 @@ class FastPath:
     def mtime(self):
         with contextlib.suppress(OSError):
             return os.stat(self.root).st_mtime
-        self.lookup.cache_clear()
 
     @method_cache
     def lookup(self, mtime):
diff --git a/importlib_metadata/_functools.py b/importlib_metadata/_functools.py
index 73f50d0..632292f 100644
--- a/importlib_metadata/_functools.py
+++ b/importlib_metadata/_functools.py
@@ -75,7 +75,7 @@ def method_cache(method, cache_wrapper=None):
     def wrapper(self, *args, **kwargs):
         # it's the first call, replace the method with a cached, bound method
         bound_method = types.MethodType(method, self)
-        cached_method = cache_wrapper(bound_method)
+        cached_method = bound_method
         setattr(self, method.__name__, cached_method)
         return cached_method(*args, **kwargs)
 

@jaraco
Copy link
Member

jaraco commented Mar 27, 2021

If I switch to a different method of memoization of that method, the problem remains:

diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py
index 11b9aac..8649630 100644
--- a/importlib_metadata/__init__.py
+++ b/importlib_metadata/__init__.py
@@ -21,7 +21,7 @@ from ._compat import (
     Protocol,
 )
 
-from ._functools import method_cache
+from ._functools import memoize
 from ._itertools import unique_everseen
 
 from configparser import ConfigParser
@@ -648,9 +648,8 @@ class FastPath:
     def mtime(self):
         with contextlib.suppress(OSError):
             return os.stat(self.root).st_mtime
-        self.lookup.cache_clear()
 
-    @method_cache
+    @memoize
     def lookup(self, mtime):
         return Lookup(self)
 
diff --git a/importlib_metadata/_functools.py b/importlib_metadata/_functools.py
index 73f50d0..f678ce0 100644
--- a/importlib_metadata/_functools.py
+++ b/importlib_metadata/_functools.py
@@ -83,3 +83,44 @@ def method_cache(method, cache_wrapper=None):
     wrapper.cache_clear = lambda: None
 
     return wrapper
+
+
+# from
+# http://code.activestate.com/recipes/577452-a-memoize-decorator-for-instance-methods/
+class memoize:
+    """cache the return value of a method
+
+    This class is meant to be used as a decorator of methods. The return value
+    from a given method invocation will be cached on the instance whose method
+    was invoked. All arguments passed to a method decorated with memoize must
+    be hashable.
+
+    If a memoized method is invoked directly on its class the result will not
+    be cached. Instead the method will be invoked like a static method:
+    class Obj(object):
+        @memoize
+        def add_to(self, arg):
+            return self + arg
+    Obj.add_to(1) # not enough arguments
+    Obj.add_to(1, 2) # returns 3, result is not cached
+    """
+    def __init__(self, func):
+        self.func = func
+
+    def __get__(self, obj, objtype=None):
+        if obj is None:
+            return self.func
+        return functools.partial(self, obj)
+
+    def __call__(self, *args, **kw):
+        obj = args[0]
+        try:
+            cache = obj.__cache
+        except AttributeError:
+            cache = obj.__cache = {}
+        key = (self.func, args[1:], frozenset(kw.items()))
+        try:
+            res = cache[key]
+        except KeyError:
+            res = cache[key] = self.func(*args, **kw)
+        return res

That indicates that it's the memoization of the lookup object that's implicated in the failure.

@omry
Copy link
Author

omry commented Mar 27, 2021

The only change between 3.7.3 and 3.8.0 is the caching (v3.7.3...v3.8.0).
Can the content of the cached object somehow change after it's created?

@jaraco
Copy link
Member

jaraco commented Mar 27, 2021

I've figured out the issue. It's in how Lookup.search uses lazy evaluation of self.info and how collections.defaultdict will add keys to the dict when none exist. So here's what happens:

  1. Pluggy searches for plugins and iterates over those plugins.
  2. In the midst of searching for plugins, Pluggy initializes plugins, including a plugin that imports jsonschema, which invokes importlib_metadata.version('jsonschema'), which invokes Lookup.search('jsonschema').
  3. The lookup of jsonschema, by dint of collections.defaultdict, creates a new entry in Lookup.infos, which is still being iterated over by Pluggy in 1.

@omry
Copy link
Author

omry commented Mar 27, 2021

Gotcha.
A solution can be to give a copy of the dict for users to iterate on.
There is a tiny perf cost but it will should work around the problem.

@jaraco
Copy link
Member

jaraco commented Mar 27, 2021

I've got a workaround going out in 3.8.1. I still want to produce a test that captures the failure, but I wanted to get the fix out asap.

@omry
Copy link
Author

omry commented Mar 27, 2021

I just tested your fix and it solves my problem.

@jaraco
Copy link
Member

jaraco commented Mar 27, 2021

Sorry for the inconvenience.

jaraco added a commit that referenced this issue Mar 27, 2021
@omry
Copy link
Author

omry commented Mar 27, 2021

No worries, thanks for fixing it so quickly.
Test looks good to me, I hope that's the last of this problem :).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants