Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
v3.8.0
======

* #290: Add mtime-based caching for ``FastPath`` and its
lookups, dramatically increasing performance for repeated
distribution lookups.

v3.7.2
======

Expand Down
91 changes: 56 additions & 35 deletions importlib_metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import functools
import itertools
import posixpath
import contextlib
import collections.abc

from ._compat import (
Expand All @@ -21,6 +22,7 @@
Protocol,
)

from ._functools import method_cache
from ._itertools import unique_everseen

from configparser import ConfigParser
Expand Down Expand Up @@ -600,9 +602,12 @@ class FastPath:
children.
"""

@functools.lru_cache() # type: ignore
def __new__(cls, root):
return super().__new__(cls)

def __init__(self, root):
self.root = str(root)
self.base = os.path.basename(self.root).lower()

def joinpath(self, child):
return pathlib.Path(self.root, child)
Expand All @@ -622,11 +627,50 @@ def zip_children(self):
return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names)

def search(self, name):
return (
self.joinpath(child)
for child in self.children()
if name.matches(child, self.base)
return self.lookup(self.mtime).search(name)

@property
def mtime(self):
with contextlib.suppress(OSError):
return os.stat(self.root).st_mtime
self.lookup.cache_clear()

@method_cache
def lookup(self, mtime):
return Lookup(self)


class Lookup:
def __init__(self, path: FastPath):
base = os.path.basename(path.root).lower()
base_is_egg = base.endswith(".egg")
self.infos = collections.defaultdict(list)
self.eggs = collections.defaultdict(list)

for child in path.children():
low = child.lower()
if low.endswith((".dist-info", ".egg-info")):
# rpartition is faster than splitext and suitable for this purpose.
name = low.rpartition(".")[0].partition("-")[0]
normalized = Prepared.normalize(name)
self.infos[normalized].append(path.joinpath(child))
elif base_is_egg and low == "egg-info":
name = base.rpartition(".")[0].partition("-")[0]
legacy_normalized = Prepared.legacy_normalize(name)
self.eggs[legacy_normalized].append(path.joinpath(child))

def search(self, prepared):
infos = (
self.infos[prepared.normalized]
if prepared
else itertools.chain.from_iterable(self.infos.values())
)
eggs = (
self.eggs[prepared.legacy_normalized]
if prepared
else itertools.chain.from_iterable(self.eggs.values())
)
return itertools.chain(infos, eggs)


class Prepared:
Expand All @@ -635,22 +679,14 @@ class Prepared:
"""

normalized = None
suffixes = 'dist-info', 'egg-info'
exact_matches = [''][:0]
egg_prefix = ''
versionless_egg_name = ''
legacy_normalized = None

def __init__(self, name):
self.name = name
if name is None:
return
self.normalized = self.normalize(name)
self.exact_matches = [
self.normalized + '.' + suffix for suffix in self.suffixes
]
legacy_normalized = self.legacy_normalize(self.name)
self.egg_prefix = legacy_normalized + '-'
self.versionless_egg_name = legacy_normalized + '.egg'
self.legacy_normalized = self.legacy_normalize(name)

@staticmethod
def normalize(name):
Expand All @@ -667,26 +703,8 @@ def legacy_normalize(name):
"""
return name.lower().replace('-', '_')

def matches(self, cand, base):
low = cand.lower()
# rpartition is faster than splitext and suitable for this purpose.
pre, _, ext = low.rpartition('.')
name, _, rest = pre.partition('-')
return (
low in self.exact_matches
or ext in self.suffixes
and (not self.normalized or name.replace('.', '_') == self.normalized)
# legacy case:
or self.is_egg(base)
and low == 'egg-info'
)

def is_egg(self, base):
return (
base == self.versionless_egg_name
or base.startswith(self.egg_prefix)
and base.endswith('.egg')
)
def __bool__(self):
return bool(self.name)


@install
Expand Down Expand Up @@ -717,6 +735,9 @@ def _search_paths(cls, name, paths):
path.search(prepared) for path in map(FastPath, paths)
)

def invalidate_caches(cls):
FastPath.__new__.cache_clear()


class PathDistribution(Distribution):
def __init__(self, path):
Expand Down
85 changes: 85 additions & 0 deletions importlib_metadata/_functools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import types
import functools


# from jaraco.functools 3.3
def method_cache(method, cache_wrapper=None):
"""
Wrap lru_cache to support storing the cache data in the object instances.

Abstracts the common paradigm where the method explicitly saves an
underscore-prefixed protected property on first call and returns that
subsequently.

>>> class MyClass:
... calls = 0
...
... @method_cache
... def method(self, value):
... self.calls += 1
... return value

>>> a = MyClass()
>>> a.method(3)
3
>>> for x in range(75):
... res = a.method(x)
>>> a.calls
75

Note that the apparent behavior will be exactly like that of lru_cache
except that the cache is stored on each instance, so values in one
instance will not flush values from another, and when an instance is
deleted, so are the cached values for that instance.

>>> b = MyClass()
>>> for x in range(35):
... res = b.method(x)
>>> b.calls
35
>>> a.method(0)
0
>>> a.calls
75

Note that if method had been decorated with ``functools.lru_cache()``,
a.calls would have been 76 (due to the cached value of 0 having been
flushed by the 'b' instance).

Clear the cache with ``.cache_clear()``

>>> a.method.cache_clear()

Same for a method that hasn't yet been called.

>>> c = MyClass()
>>> c.method.cache_clear()

Another cache wrapper may be supplied:

>>> cache = functools.lru_cache(maxsize=2)
>>> MyClass.method2 = method_cache(lambda self: 3, cache_wrapper=cache)
>>> a = MyClass()
>>> a.method2()
3

Caution - do not subsequently wrap the method with another decorator, such
as ``@property``, which changes the semantics of the function.

See also
http://code.activestate.com/recipes/577452-a-memoize-decorator-for-instance-methods/
for another implementation and additional justification.
"""
cache_wrapper = cache_wrapper or functools.lru_cache()

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)
setattr(self, method.__name__, cached_method)
return cached_method(*args, **kwargs)

# Support cache clear even before cache has been created.
wrapper.cache_clear = lambda: None

return wrapper
7 changes: 7 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import textwrap
import unittest
import warnings
import importlib

from . import fixtures
from importlib_metadata import (
Expand Down Expand Up @@ -275,3 +276,9 @@ def test_distribution_at_str(self):
dist_info_path = self.site_dir / 'distinfo_pkg-1.0.0.dist-info'
dist = Distribution.at(str(dist_info_path))
assert dist.version == '1.0.0'


class InvalidateCache(unittest.TestCase):
def test_invalidate_cache(self):
# No externally observable behavior, but ensures test coverage...
importlib.invalidate_caches()