Skip to content

Commit cc3e285

Browse files
Cache Package Version Lookups (#946)
* Cache _get_package_version * Add Python 2.7 support to get_package_version caching * [Mega-Linter] Apply linters fixes * Bump tests --------- Co-authored-by: SlavaSkvortsov <[email protected]> Co-authored-by: TimPansino <[email protected]>
1 parent 43160af commit cc3e285

File tree

2 files changed

+62
-3
lines changed

2 files changed

+62
-3
lines changed

newrelic/common/package_version_utils.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,44 @@
1414

1515
import sys
1616

17+
try:
18+
from functools import cache as _cache_package_versions
19+
except ImportError:
20+
from functools import wraps
21+
from threading import Lock
22+
23+
_package_version_cache = {}
24+
_package_version_cache_lock = Lock()
25+
26+
def _cache_package_versions(wrapped):
27+
"""
28+
Threadsafe implementation of caching for _get_package_version.
29+
30+
Python 2.7 does not have the @functools.cache decorator, and
31+
must be reimplemented with support for clearing the cache.
32+
"""
33+
34+
@wraps(wrapped)
35+
def _wrapper(name):
36+
if name in _package_version_cache:
37+
return _package_version_cache[name]
38+
39+
with _package_version_cache_lock:
40+
if name in _package_version_cache:
41+
return _package_version_cache[name]
42+
43+
version = _package_version_cache[name] = wrapped(name)
44+
return version
45+
46+
def cache_clear():
47+
"""Cache clear function to mimic @functools.cache"""
48+
with _package_version_cache_lock:
49+
_package_version_cache.clear()
50+
51+
_wrapper.cache_clear = cache_clear
52+
return _wrapper
53+
54+
1755
# Need to account for 4 possible variations of version declaration specified in (rejected) PEP 396
1856
VERSION_ATTRS = ("__version__", "version", "__version_tuple__", "version_tuple") # nosec
1957
NULL_VERSIONS = frozenset((None, "", "0", "0.0", "0.0.0", "0.0.0.0", (0,), (0, 0), (0, 0, 0), (0, 0, 0, 0))) # nosec
@@ -67,6 +105,7 @@ def int_or_str(value):
67105
return version
68106

69107

108+
@_cache_package_versions
70109
def _get_package_version(name):
71110
module = sys.modules.get(name, None)
72111
version = None
@@ -75,7 +114,7 @@ def _get_package_version(name):
75114
if "importlib" in sys.modules and hasattr(sys.modules["importlib"], "metadata"):
76115
try:
77116
# In Python3.10+ packages_distribution can be checked for as well
78-
if hasattr(sys.modules["importlib"].metadata, "packages_distributions"): # pylint: disable=E1101
117+
if hasattr(sys.modules["importlib"].metadata, "packages_distributions"): # pylint: disable=E1101
79118
distributions = sys.modules["importlib"].metadata.packages_distributions() # pylint: disable=E1101
80119
distribution_name = distributions.get(name, name)
81120
else:

tests/agent_unittests/test_package_version_utils.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from newrelic.common.package_version_utils import (
2121
NULL_VERSIONS,
2222
VERSION_ATTRS,
23+
_get_package_version,
2324
get_package_version,
2425
get_package_version_tuple,
2526
)
@@ -31,7 +32,7 @@
3132
# such as distribution_packages and removed pkg_resources.
3233

3334
IS_PY38_PLUS = sys.version_info[:2] >= (3, 8)
34-
IS_PY310_PLUS = sys.version_info[:2] >= (3,10)
35+
IS_PY310_PLUS = sys.version_info[:2] >= (3, 10)
3536
SKIP_IF_NOT_IMPORTLIB_METADATA = pytest.mark.skipif(not IS_PY38_PLUS, reason="importlib.metadata is not supported.")
3637
SKIP_IF_IMPORTLIB_METADATA = pytest.mark.skipif(
3738
IS_PY38_PLUS, reason="importlib.metadata is preferred over pkg_resources."
@@ -46,7 +47,13 @@ def patched_pytest_module(monkeypatch):
4647
monkeypatch.delattr(pytest, attr)
4748

4849
yield pytest
49-
50+
51+
52+
@pytest.fixture(scope="function", autouse=True)
53+
def cleared_package_version_cache():
54+
"""Ensure cache is empty before every test to exercise code paths."""
55+
_get_package_version.cache_clear()
56+
5057

5158
# This test only works on Python 3.7
5259
@SKIP_IF_IMPORTLIB_METADATA
@@ -123,3 +130,16 @@ def test_mapping_import_to_distribution_packages():
123130
def test_pkg_resources_metadata():
124131
version = get_package_version("pytest")
125132
assert version not in NULL_VERSIONS, version
133+
134+
135+
def test_version_caching(monkeypatch):
136+
# Add fake module to be deleted later
137+
sys.modules["mymodule"] = sys.modules["pytest"]
138+
setattr(pytest, "__version__", "1.0.0")
139+
version = get_package_version("mymodule")
140+
assert version not in NULL_VERSIONS, version
141+
142+
# Ensure after deleting that the call to _get_package_version still completes because of caching
143+
del sys.modules["mymodule"]
144+
version = get_package_version("mymodule")
145+
assert version not in NULL_VERSIONS, version

0 commit comments

Comments
 (0)