From 5ee3c181b38e5bec7df0388509368057f4b04aa2 Mon Sep 17 00:00:00 2001 From: Ivana Kellyerova Date: Fri, 24 Nov 2023 11:02:31 +0100 Subject: [PATCH] Move installed modules code to utils (#2429) Even though we're now using the `_get_installed_modules` function in many different places, it still lives in `sentry_sdk.integrations.modules`. With this change we move `_get_installed_modules` (and related helpers) to `utils.py` and introduce a new `package_version` helper function (also in `utils.py`) that finds out and parses the version of a package in one go. --- sentry_sdk/integrations/ariadne.py | 8 +- sentry_sdk/integrations/asgi.py | 2 +- sentry_sdk/integrations/flask.py | 10 +- sentry_sdk/integrations/graphene.py | 8 +- sentry_sdk/integrations/modules.py | 46 +----- .../integrations/opentelemetry/integration.py | 3 +- sentry_sdk/integrations/strawberry.py | 7 +- sentry_sdk/utils.py | 155 ++++++++++++------ tests/integrations/modules/test_modules.py | 59 +------ tests/test_utils.py | 69 ++++++++ 10 files changed, 188 insertions(+), 179 deletions(-) diff --git a/sentry_sdk/integrations/ariadne.py b/sentry_sdk/integrations/ariadne.py index 8025860a6f..86d6b5e28e 100644 --- a/sentry_sdk/integrations/ariadne.py +++ b/sentry_sdk/integrations/ariadne.py @@ -3,12 +3,11 @@ from sentry_sdk.hub import Hub, _should_send_default_pii from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk.integrations.modules import _get_installed_modules from sentry_sdk.integrations._wsgi_common import request_body_within_bounds from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, - parse_version, + package_version, ) from sentry_sdk._types import TYPE_CHECKING @@ -33,11 +32,10 @@ class AriadneIntegration(Integration): @staticmethod def setup_once(): # type: () -> None - installed_packages = _get_installed_modules() - version = parse_version(installed_packages["ariadne"]) + version = package_version("ariadne") if version is None: - raise DidNotEnable("Unparsable ariadne version: {}".format(version)) + raise DidNotEnable("Unparsable ariadne version.") if version < (0, 20): raise DidNotEnable("ariadne 0.20 or newer required.") diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 2cecdf9a81..901c6f5d23 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -19,7 +19,6 @@ _get_request_data, _get_url, ) -from sentry_sdk.integrations.modules import _get_installed_modules from sentry_sdk.sessions import auto_session_tracking from sentry_sdk.tracing import ( SOURCE_FOR_STYLE, @@ -34,6 +33,7 @@ CONTEXTVARS_ERROR_MESSAGE, logger, transaction_from_function, + _get_installed_modules, ) from sentry_sdk.tracing import Transaction diff --git a/sentry_sdk/integrations/flask.py b/sentry_sdk/integrations/flask.py index 0da411c23d..453ab48ce3 100644 --- a/sentry_sdk/integrations/flask.py +++ b/sentry_sdk/integrations/flask.py @@ -5,13 +5,12 @@ from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations._wsgi_common import RequestExtractor from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware -from sentry_sdk.integrations.modules import _get_installed_modules from sentry_sdk.scope import Scope from sentry_sdk.tracing import SOURCE_FOR_STYLE from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, - parse_version, + package_version, ) if TYPE_CHECKING: @@ -64,13 +63,10 @@ def __init__(self, transaction_style="endpoint"): @staticmethod def setup_once(): # type: () -> None - - installed_packages = _get_installed_modules() - flask_version = installed_packages["flask"] - version = parse_version(flask_version) + version = package_version("flask") if version is None: - raise DidNotEnable("Unparsable Flask version: {}".format(flask_version)) + raise DidNotEnable("Unparsable Flask version.") if version < (0, 10): raise DidNotEnable("Flask 0.10 or newer is required.") diff --git a/sentry_sdk/integrations/graphene.py b/sentry_sdk/integrations/graphene.py index 5d3c656145..fa753d0812 100644 --- a/sentry_sdk/integrations/graphene.py +++ b/sentry_sdk/integrations/graphene.py @@ -1,10 +1,9 @@ from sentry_sdk.hub import Hub, _should_send_default_pii from sentry_sdk.integrations import DidNotEnable, Integration -from sentry_sdk.integrations.modules import _get_installed_modules from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, - parse_version, + package_version, ) from sentry_sdk._types import TYPE_CHECKING @@ -28,11 +27,10 @@ class GrapheneIntegration(Integration): @staticmethod def setup_once(): # type: () -> None - installed_packages = _get_installed_modules() - version = parse_version(installed_packages["graphene"]) + version = package_version("graphene") if version is None: - raise DidNotEnable("Unparsable graphene version: {}".format(version)) + raise DidNotEnable("Unparsable graphene version.") if version < (3, 3): raise DidNotEnable("graphene 3.3 or newer required.") diff --git a/sentry_sdk/integrations/modules.py b/sentry_sdk/integrations/modules.py index 3f9f356eed..5b595b4032 100644 --- a/sentry_sdk/integrations/modules.py +++ b/sentry_sdk/integrations/modules.py @@ -3,61 +3,17 @@ from sentry_sdk.hub import Hub from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor +from sentry_sdk.utils import _get_installed_modules from sentry_sdk._types import TYPE_CHECKING if TYPE_CHECKING: from typing import Any from typing import Dict - from typing import Tuple - from typing import Iterator from sentry_sdk._types import Event -_installed_modules = None - - -def _normalize_module_name(name): - # type: (str) -> str - return name.lower() - - -def _generate_installed_modules(): - # type: () -> Iterator[Tuple[str, str]] - try: - from importlib import metadata - - for dist in metadata.distributions(): - name = dist.metadata["Name"] - # `metadata` values may be `None`, see: - # https://github.com/python/cpython/issues/91216 - # and - # https://github.com/python/importlib_metadata/issues/371 - if name is not None: - version = metadata.version(name) - if version is not None: - yield _normalize_module_name(name), version - - except ImportError: - # < py3.8 - try: - import pkg_resources - except ImportError: - return - - for info in pkg_resources.working_set: - yield _normalize_module_name(info.key), info.version - - -def _get_installed_modules(): - # type: () -> Dict[str, str] - global _installed_modules - if _installed_modules is None: - _installed_modules = dict(_generate_installed_modules()) - return _installed_modules - - class ModulesIntegration(Integration): identifier = "modules" diff --git a/sentry_sdk/integrations/opentelemetry/integration.py b/sentry_sdk/integrations/opentelemetry/integration.py index 20dc4625df..e1a4318f67 100644 --- a/sentry_sdk/integrations/opentelemetry/integration.py +++ b/sentry_sdk/integrations/opentelemetry/integration.py @@ -9,8 +9,7 @@ from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations.opentelemetry.span_processor import SentrySpanProcessor from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator -from sentry_sdk.integrations.modules import _get_installed_modules -from sentry_sdk.utils import logger +from sentry_sdk.utils import logger, _get_installed_modules from sentry_sdk._types import TYPE_CHECKING try: diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index 63ddc44f25..8f4314f663 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -5,13 +5,13 @@ from sentry_sdk.consts import OP from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk.integrations.modules import _get_installed_modules from sentry_sdk.hub import Hub, _should_send_default_pii from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, logger, - parse_version, + package_version, + _get_installed_modules, ) from sentry_sdk._types import TYPE_CHECKING @@ -55,8 +55,7 @@ def __init__(self, async_execution=None): @staticmethod def setup_once(): # type: () -> None - installed_packages = _get_installed_modules() - version = parse_version(installed_packages["strawberry-graphql"]) + version = package_version("strawberry-graphql") if version is None: raise DidNotEnable( diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 3b83fb2607..e739290897 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -76,6 +76,7 @@ # The logger is created here but initialized in the debug support module logger = logging.getLogger("sentry_sdk.errors") +_installed_modules = None BASE64_ALPHABET = re.compile(r"^[a-zA-Z0-9/+=]*$") @@ -1126,58 +1127,6 @@ def strip_string(value, max_length=None): return value -def parse_version(version): - # type: (str) -> Optional[Tuple[int, ...]] - """ - Parses a version string into a tuple of integers. - This uses the parsing loging from PEP 440: - https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions - """ - VERSION_PATTERN = r""" # noqa: N806 - v? - (?: - (?:(?P[0-9]+)!)? # epoch - (?P[0-9]+(?:\.[0-9]+)*) # release segment - (?P
                                          # pre-release
-                [-_\.]?
-                (?P(a|b|c|rc|alpha|beta|pre|preview))
-                [-_\.]?
-                (?P[0-9]+)?
-            )?
-            (?P                                         # post release
-                (?:-(?P[0-9]+))
-                |
-                (?:
-                    [-_\.]?
-                    (?Ppost|rev|r)
-                    [-_\.]?
-                    (?P[0-9]+)?
-                )
-            )?
-            (?P                                          # dev release
-                [-_\.]?
-                (?Pdev)
-                [-_\.]?
-                (?P[0-9]+)?
-            )?
-        )
-        (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
-    """
-
-    pattern = re.compile(
-        r"^\s*" + VERSION_PATTERN + r"\s*$",
-        re.VERBOSE | re.IGNORECASE,
-    )
-
-    try:
-        release = pattern.match(version).groupdict()["release"]  # type: ignore
-        release_tuple = tuple(map(int, release.split(".")[:3]))  # type: Tuple[int, ...]
-    except (TypeError, ValueError, AttributeError):
-        return None
-
-    return release_tuple
-
-
 def _is_contextvars_broken():
     # type: () -> bool
     """
@@ -1572,6 +1521,108 @@ def is_sentry_url(hub, url):
     )
 
 
+def parse_version(version):
+    # type: (str) -> Optional[Tuple[int, ...]]
+    """
+    Parses a version string into a tuple of integers.
+    This uses the parsing loging from PEP 440:
+    https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions
+    """
+    VERSION_PATTERN = r"""  # noqa: N806
+        v?
+        (?:
+            (?:(?P[0-9]+)!)?                           # epoch
+            (?P[0-9]+(?:\.[0-9]+)*)                  # release segment
+            (?P
                                          # pre-release
+                [-_\.]?
+                (?P(a|b|c|rc|alpha|beta|pre|preview))
+                [-_\.]?
+                (?P[0-9]+)?
+            )?
+            (?P                                         # post release
+                (?:-(?P[0-9]+))
+                |
+                (?:
+                    [-_\.]?
+                    (?Ppost|rev|r)
+                    [-_\.]?
+                    (?P[0-9]+)?
+                )
+            )?
+            (?P                                          # dev release
+                [-_\.]?
+                (?Pdev)
+                [-_\.]?
+                (?P[0-9]+)?
+            )?
+        )
+        (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+    """
+
+    pattern = re.compile(
+        r"^\s*" + VERSION_PATTERN + r"\s*$",
+        re.VERBOSE | re.IGNORECASE,
+    )
+
+    try:
+        release = pattern.match(version).groupdict()["release"]  # type: ignore
+        release_tuple = tuple(map(int, release.split(".")[:3]))  # type: Tuple[int, ...]
+    except (TypeError, ValueError, AttributeError):
+        return None
+
+    return release_tuple
+
+
+def _generate_installed_modules():
+    # type: () -> Iterator[Tuple[str, str]]
+    try:
+        from importlib import metadata
+
+        for dist in metadata.distributions():
+            name = dist.metadata["Name"]
+            # `metadata` values may be `None`, see:
+            # https://github.com/python/cpython/issues/91216
+            # and
+            # https://github.com/python/importlib_metadata/issues/371
+            if name is not None:
+                version = metadata.version(name)
+                if version is not None:
+                    yield _normalize_module_name(name), version
+
+    except ImportError:
+        # < py3.8
+        try:
+            import pkg_resources
+        except ImportError:
+            return
+
+        for info in pkg_resources.working_set:
+            yield _normalize_module_name(info.key), info.version
+
+
+def _normalize_module_name(name):
+    # type: (str) -> str
+    return name.lower()
+
+
+def _get_installed_modules():
+    # type: () -> Dict[str, str]
+    global _installed_modules
+    if _installed_modules is None:
+        _installed_modules = dict(_generate_installed_modules())
+    return _installed_modules
+
+
+def package_version(package):
+    # type: (str) -> Optional[Tuple[int, ...]]
+    installed_packages = _get_installed_modules()
+    version = installed_packages.get(package)
+    if version is None:
+        return None
+
+    return parse_version(version)
+
+
 if PY37:
 
     def nanosecond_time():
diff --git a/tests/integrations/modules/test_modules.py b/tests/integrations/modules/test_modules.py
index c7097972b0..3f4d7bd9dc 100644
--- a/tests/integrations/modules/test_modules.py
+++ b/tests/integrations/modules/test_modules.py
@@ -1,22 +1,6 @@
-import pytest
-import re
 import sentry_sdk
 
-from sentry_sdk.integrations.modules import (
-    ModulesIntegration,
-    _get_installed_modules,
-)
-
-
-def _normalize_distribution_name(name):
-    # type: (str) -> str
-    """Normalize distribution name according to PEP-0503.
-
-    See:
-    https://peps.python.org/pep-0503/#normalized-names
-    for more details.
-    """
-    return re.sub(r"[-_.]+", "-", name).lower()
+from sentry_sdk.integrations.modules import ModulesIntegration
 
 
 def test_basic(sentry_init, capture_events):
@@ -28,44 +12,3 @@ def test_basic(sentry_init, capture_events):
     (event,) = events
     assert "sentry-sdk" in event["modules"]
     assert "pytest" in event["modules"]
-
-
-def test_installed_modules():
-    try:
-        from importlib.metadata import distributions, version
-
-        importlib_available = True
-    except ImportError:
-        importlib_available = False
-
-    try:
-        import pkg_resources
-
-        pkg_resources_available = True
-    except ImportError:
-        pkg_resources_available = False
-
-    installed_distributions = {
-        _normalize_distribution_name(dist): version
-        for dist, version in _get_installed_modules().items()
-    }
-
-    if importlib_available:
-        importlib_distributions = {
-            _normalize_distribution_name(dist.metadata["Name"]): version(
-                dist.metadata["Name"]
-            )
-            for dist in distributions()
-            if dist.metadata["Name"] is not None
-            and version(dist.metadata["Name"]) is not None
-        }
-        assert installed_distributions == importlib_distributions
-
-    elif pkg_resources_available:
-        pkg_resources_distributions = {
-            _normalize_distribution_name(dist.key): dist.version
-            for dist in pkg_resources.working_set
-        }
-        assert installed_distributions == pkg_resources_distributions
-    else:
-        pytest.fail("Neither importlib nor pkg_resources is available")
diff --git a/tests/test_utils.py b/tests/test_utils.py
index ee73433dd5..efbfa7504b 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -15,6 +15,7 @@
     sanitize_url,
     serialize_frame,
     is_sentry_url,
+    _get_installed_modules,
 )
 
 import sentry_sdk
@@ -25,6 +26,17 @@
     import mock  # python < 3.3
 
 
+def _normalize_distribution_name(name):
+    # type: (str) -> str
+    """Normalize distribution name according to PEP-0503.
+
+    See:
+    https://peps.python.org/pep-0503/#normalized-names
+    for more details.
+    """
+    return re.sub(r"[-_.]+", "-", name).lower()
+
+
 @pytest.mark.parametrize(
     ("url", "expected_result"),
     [
@@ -488,3 +500,60 @@ def test_get_error_message(error, expected_result):
         exc_value.detail = error
         raise Exception
     assert get_error_message(exc_value) == expected_result(exc_value)
+
+
+def test_installed_modules():
+    try:
+        from importlib.metadata import distributions, version
+
+        importlib_available = True
+    except ImportError:
+        importlib_available = False
+
+    try:
+        import pkg_resources
+
+        pkg_resources_available = True
+    except ImportError:
+        pkg_resources_available = False
+
+    installed_distributions = {
+        _normalize_distribution_name(dist): version
+        for dist, version in _get_installed_modules().items()
+    }
+
+    if importlib_available:
+        importlib_distributions = {
+            _normalize_distribution_name(dist.metadata["Name"]): version(
+                dist.metadata["Name"]
+            )
+            for dist in distributions()
+            if dist.metadata["Name"] is not None
+            and version(dist.metadata["Name"]) is not None
+        }
+        assert installed_distributions == importlib_distributions
+
+    elif pkg_resources_available:
+        pkg_resources_distributions = {
+            _normalize_distribution_name(dist.key): dist.version
+            for dist in pkg_resources.working_set
+        }
+        assert installed_distributions == pkg_resources_distributions
+    else:
+        pytest.fail("Neither importlib nor pkg_resources is available")
+
+
+def test_installed_modules_caching():
+    mock_generate_installed_modules = mock.Mock()
+    mock_generate_installed_modules.return_value = {"package": "1.0.0"}
+    with mock.patch("sentry_sdk.utils._installed_modules", None):
+        with mock.patch(
+            "sentry_sdk.utils._generate_installed_modules",
+            mock_generate_installed_modules,
+        ):
+            _get_installed_modules()
+            assert mock_generate_installed_modules.called
+            mock_generate_installed_modules.reset_mock()
+
+            _get_installed_modules()
+            mock_generate_installed_modules.assert_not_called()