diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index 8b6c783530..e412fd931d 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -15,6 +15,7 @@ from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, + parse_version, transaction_from_function, HAS_REAL_CONTEXTVARS, CONTEXTVARS_ERROR_MESSAGE, @@ -64,10 +65,10 @@ def __init__(self, transaction_style="handler_name"): def setup_once(): # type: () -> None - try: - version = tuple(map(int, AIOHTTP_VERSION.split(".")[:2])) - except (TypeError, ValueError): - raise DidNotEnable("AIOHTTP version unparsable: {}".format(AIOHTTP_VERSION)) + version = parse_version(AIOHTTP_VERSION) + + if version is None: + raise DidNotEnable("Unparsable AIOHTTP version: {}".format(AIOHTTP_VERSION)) if version < (3, 4): raise DidNotEnable("AIOHTTP 3.4 or newer required.") diff --git a/sentry_sdk/integrations/arq.py b/sentry_sdk/integrations/arq.py index 1a6ba0e7c4..684533b6f9 100644 --- a/sentry_sdk/integrations/arq.py +++ b/sentry_sdk/integrations/arq.py @@ -14,6 +14,7 @@ capture_internal_exceptions, event_from_exception, SENSITIVE_DATA_SUBSTITUTE, + parse_version, ) try: @@ -45,11 +46,15 @@ def setup_once(): try: if isinstance(ARQ_VERSION, str): - version = tuple(map(int, ARQ_VERSION.split(".")[:2])) + version = parse_version(ARQ_VERSION) else: version = ARQ_VERSION.version[:2] + except (TypeError, ValueError): - raise DidNotEnable("arq version unparsable: {}".format(ARQ_VERSION)) + version = None + + if version is None: + raise DidNotEnable("Unparsable arq version: {}".format(ARQ_VERSION)) if version < (0, 23): raise DidNotEnable("arq 0.23 or newer required.") diff --git a/sentry_sdk/integrations/boto3.py b/sentry_sdk/integrations/boto3.py index a4eb400666..d8e505b593 100644 --- a/sentry_sdk/integrations/boto3.py +++ b/sentry_sdk/integrations/boto3.py @@ -7,7 +7,7 @@ from sentry_sdk._functools import partial from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.utils import parse_url +from sentry_sdk.utils import parse_url, parse_version if TYPE_CHECKING: from typing import Any @@ -30,14 +30,17 @@ class Boto3Integration(Integration): @staticmethod def setup_once(): # type: () -> None - try: - version = tuple(map(int, BOTOCORE_VERSION.split(".")[:3])) - except (ValueError, TypeError): + + version = parse_version(BOTOCORE_VERSION) + + if version is None: raise DidNotEnable( "Unparsable botocore version: {}".format(BOTOCORE_VERSION) ) + if version < (1, 12): raise DidNotEnable("Botocore 1.12 or newer is required.") + orig_init = BaseClient.__init__ def sentry_patched_init(self, *args, **kwargs): diff --git a/sentry_sdk/integrations/bottle.py b/sentry_sdk/integrations/bottle.py index 71c4f127f6..cc6360daa3 100644 --- a/sentry_sdk/integrations/bottle.py +++ b/sentry_sdk/integrations/bottle.py @@ -5,6 +5,7 @@ from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, + parse_version, transaction_from_function, ) from sentry_sdk.integrations import Integration, DidNotEnable @@ -57,10 +58,10 @@ def __init__(self, transaction_style="endpoint"): def setup_once(): # type: () -> None - try: - version = tuple(map(int, BOTTLE_VERSION.replace("-dev", "").split("."))) - except (TypeError, ValueError): - raise DidNotEnable("Unparsable Bottle version: {}".format(version)) + version = parse_version(BOTTLE_VERSION) + + if version is None: + raise DidNotEnable("Unparsable Bottle version: {}".format(BOTTLE_VERSION)) if version < (0, 12): raise DidNotEnable("Bottle 0.12 or newer required.") diff --git a/sentry_sdk/integrations/chalice.py b/sentry_sdk/integrations/chalice.py index 6381850560..25d8b4ac52 100644 --- a/sentry_sdk/integrations/chalice.py +++ b/sentry_sdk/integrations/chalice.py @@ -8,6 +8,7 @@ from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, + parse_version, ) from sentry_sdk._types import TYPE_CHECKING from sentry_sdk._functools import wraps @@ -102,10 +103,12 @@ class ChaliceIntegration(Integration): @staticmethod def setup_once(): # type: () -> None - try: - version = tuple(map(int, CHALICE_VERSION.split(".")[:3])) - except (ValueError, TypeError): + + version = parse_version(CHALICE_VERSION) + + if version is None: raise DidNotEnable("Unparsable Chalice version: {}".format(CHALICE_VERSION)) + if version < (1, 20): old_get_view_function_response = Chalice._get_view_function_response else: diff --git a/sentry_sdk/integrations/falcon.py b/sentry_sdk/integrations/falcon.py index f4bc361fa7..1bb79428f1 100644 --- a/sentry_sdk/integrations/falcon.py +++ b/sentry_sdk/integrations/falcon.py @@ -8,6 +8,7 @@ from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, + parse_version, ) from sentry_sdk._types import TYPE_CHECKING @@ -131,9 +132,10 @@ def __init__(self, transaction_style="uri_template"): @staticmethod def setup_once(): # type: () -> None - try: - version = tuple(map(int, FALCON_VERSION.split("."))) - except (ValueError, TypeError): + + version = parse_version(FALCON_VERSION) + + if version is None: raise DidNotEnable("Unparsable Falcon version: {}".format(FALCON_VERSION)) if version < (1, 4): diff --git a/sentry_sdk/integrations/flask.py b/sentry_sdk/integrations/flask.py index ea5a3c081a..47e96edd3c 100644 --- a/sentry_sdk/integrations/flask.py +++ b/sentry_sdk/integrations/flask.py @@ -10,6 +10,7 @@ from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, + parse_version, ) if TYPE_CHECKING: @@ -64,16 +65,13 @@ def __init__(self, transaction_style="endpoint"): def setup_once(): # type: () -> None - # This version parsing is absolutely naive but the alternative is to - # import pkg_resources which slows down the SDK a lot. - try: - version = tuple(map(int, FLASK_VERSION.split(".")[:3])) - except (ValueError, TypeError): - # It's probably a release candidate, we assume it's fine. - pass - else: - if version < (0, 10): - raise DidNotEnable("Flask 0.10 or newer is required.") + version = parse_version(FLASK_VERSION) + + if version is None: + raise DidNotEnable("Unparsable Flask version: {}".format(FLASK_VERSION)) + + if version < (0, 10): + raise DidNotEnable("Flask 0.10 or newer is required.") before_render_template.connect(_add_sentry_trace) request_started.connect(_request_started) diff --git a/sentry_sdk/integrations/rq.py b/sentry_sdk/integrations/rq.py index 2696cbff3c..f3cff154bf 100644 --- a/sentry_sdk/integrations/rq.py +++ b/sentry_sdk/integrations/rq.py @@ -11,6 +11,7 @@ capture_internal_exceptions, event_from_exception, format_timestamp, + parse_version, ) try: @@ -39,9 +40,9 @@ class RqIntegration(Integration): def setup_once(): # type: () -> None - try: - version = tuple(map(int, RQ_VERSION.split(".")[:3])) - except (ValueError, TypeError): + version = parse_version(RQ_VERSION) + + if version is None: raise DidNotEnable("Unparsable RQ version: {}".format(RQ_VERSION)) if version < (0, 6): diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py index e6838ab9b0..f9474d6bb6 100644 --- a/sentry_sdk/integrations/sanic.py +++ b/sentry_sdk/integrations/sanic.py @@ -10,6 +10,7 @@ event_from_exception, HAS_REAL_CONTEXTVARS, CONTEXTVARS_ERROR_MESSAGE, + parse_version, ) from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations._wsgi_common import RequestExtractor, _filter_headers @@ -51,15 +52,15 @@ class SanicIntegration(Integration): identifier = "sanic" - version = (0, 0) # type: Tuple[int, ...] + version = None @staticmethod def setup_once(): # type: () -> None - try: - SanicIntegration.version = tuple(map(int, SANIC_VERSION.split("."))) - except (TypeError, ValueError): + SanicIntegration.version = parse_version(SANIC_VERSION) + + if SanicIntegration.version is None: raise DidNotEnable("Unparsable Sanic version: {}".format(SANIC_VERSION)) if SanicIntegration.version < (0, 8): @@ -225,7 +226,7 @@ async def sentry_wrapped_error_handler(request, exception): finally: # As mentioned in previous comment in _startup, this can be removed # after https://github.com/sanic-org/sanic/issues/2297 is resolved - if SanicIntegration.version == (21, 9): + if SanicIntegration.version and SanicIntegration.version == (21, 9): await _hub_exit(request) return sentry_wrapped_error_handler diff --git a/sentry_sdk/integrations/sqlalchemy.py b/sentry_sdk/integrations/sqlalchemy.py index 5c5adec86d..168aca9e04 100644 --- a/sentry_sdk/integrations/sqlalchemy.py +++ b/sentry_sdk/integrations/sqlalchemy.py @@ -1,7 +1,5 @@ from __future__ import absolute_import -import re - from sentry_sdk._compat import text_type from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import SPANDATA @@ -9,6 +7,8 @@ from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.tracing_utils import record_sql_queries +from sentry_sdk.utils import parse_version + try: from sqlalchemy.engine import Engine # type: ignore from sqlalchemy.event import listen # type: ignore @@ -31,11 +31,9 @@ class SqlalchemyIntegration(Integration): def setup_once(): # type: () -> None - try: - version = tuple( - map(int, re.split("b|rc", SQLALCHEMY_VERSION)[0].split(".")) - ) - except (TypeError, ValueError): + version = parse_version(SQLALCHEMY_VERSION) + + if version is None: raise DidNotEnable( "Unparsable SQLAlchemy version: {}".format(SQLALCHEMY_VERSION) ) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 58f46e2955..fa9ae15be9 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -1469,6 +1469,58 @@ def match_regex_list(item, regex_list=None, substring_matching=False): return False +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
+
+
 if PY37:
 
     def nanosecond_time():
diff --git a/tests/test_utils.py b/tests/test_utils.py
index ed8c49b56a..53e3025b98 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -7,6 +7,7 @@
     logger,
     match_regex_list,
     parse_url,
+    parse_version,
     sanitize_url,
     serialize_frame,
 )
@@ -263,3 +264,39 @@ def test_include_source_context_when_serializing_frame(include_source_context):
 )
 def test_match_regex_list(item, regex_list, expected_result):
     assert match_regex_list(item, regex_list) == expected_result
+
+
+@pytest.mark.parametrize(
+    "version,expected_result",
+    [
+        ["3.5.15", (3, 5, 15)],
+        ["2.0.9", (2, 0, 9)],
+        ["2.0.0", (2, 0, 0)],
+        ["0.6.0", (0, 6, 0)],
+        ["2.0.0.post1", (2, 0, 0)],
+        ["2.0.0rc3", (2, 0, 0)],
+        ["2.0.0rc2", (2, 0, 0)],
+        ["2.0.0rc1", (2, 0, 0)],
+        ["2.0.0b4", (2, 0, 0)],
+        ["2.0.0b3", (2, 0, 0)],
+        ["2.0.0b2", (2, 0, 0)],
+        ["2.0.0b1", (2, 0, 0)],
+        ["0.6beta3", (0, 6)],
+        ["0.6beta2", (0, 6)],
+        ["0.6beta1", (0, 6)],
+        ["0.4.2b", (0, 4, 2)],
+        ["0.4.2a", (0, 4, 2)],
+        ["0.0.1", (0, 0, 1)],
+        ["0.0.0", (0, 0, 0)],
+        ["1", (1,)],
+        ["1.0", (1, 0)],
+        ["1.0.0", (1, 0, 0)],
+        [" 1.0.0 ", (1, 0, 0)],
+        ["  1.0.0   ", (1, 0, 0)],
+        ["x1.0.0", None],
+        ["1.0.0x", None],
+        ["x1.0.0x", None],
+    ],
+)
+def test_parse_version(version, expected_result):
+    assert parse_version(version) == expected_result