Skip to content

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

Closed

Description

@omry

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

Activity

  1. m-melis commented on Mar 27, 2021

    @m-melis

    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.

  2. jaraco commented on Mar 27, 2021

    @jaraco
    Member

    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?

  3. jaraco commented on Mar 27, 2021

    @jaraco
    Member

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

  4. jaraco commented on Mar 27, 2021

    @jaraco
    Member

    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)
     
  5. jaraco commented on Mar 27, 2021

    @jaraco
    Member

    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.

  6. omry commented on Mar 27, 2021

    @omry
    Author

    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?

  7. jaraco commented on Mar 27, 2021

    @jaraco
    Member

    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.
  8. added a commit that references this issue on Mar 27, 2021
  9. omry commented on Mar 27, 2021

    @omry
    Author

    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.

  10. jaraco commented on Mar 27, 2021

    @jaraco
    Member

    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.

  11. omry commented on Mar 27, 2021

    @omry
    Author

    I just tested your fix and it solves my problem.

  12. jaraco commented on Mar 27, 2021

    @jaraco
    Member

    Sorry for the inconvenience.

  13. added a commit that references this issue on Mar 27, 2021
  14. omry commented on Mar 27, 2021

    @omry
    Author

    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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions