diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 777c363e14..ff5a7d038d 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -1,6 +1,7 @@ """This package""" from __future__ import absolute_import +from re import compile, VERBOSE, IGNORECASE from threading import Lock from sentry_sdk._compat import iteritems @@ -22,6 +23,39 @@ _installed_integrations = set() # type: Set[str] +# Version Pattern as defined in PEP 440 +VERSION_PATTERN = r""" + 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
+"""
+
+
 def _generate_default_integrations_iterator(integrations, auto_enabling_integrations):
     # type: (Tuple[str, ...], Tuple[str, ...]) -> Callable[[bool], Iterator[Type[Integration]]]
 
@@ -117,7 +151,7 @@ def setup_integrations(
                     "Setting up previously not enabled integration %s", identifier
                 )
                 try:
-                    type(integration).setup_once()
+                    integration.setup_once()
                 except NotImplementedError:
                     if getattr(integration, "install", None) is not None:
                         logger.warning(
@@ -167,6 +201,18 @@ class Integration(object):
     identifier = None  # type: str
     """String unique ID of integration type"""
 
+    @staticmethod
+    def parse_version(version):
+        # type: (str) -> Tuple[int, ...]
+        """
+        Utility to parse project version according to PEP 440.
+        """
+        _regex = compile(r"^\s*" + VERSION_PATTERN + r"\s*$", VERBOSE | IGNORECASE)
+        match = _regex.search(version)
+        if not match:
+            raise DidNotEnable("Invalid version detected: %s", version)
+        return tuple(map(int, match.group("release").split(".")))
+
     @staticmethod
     def setup_once():
         # type: () -> None
diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py
index 8a828b2fe3..9294c148c0 100644
--- a/sentry_sdk/integrations/aiohttp.py
+++ b/sentry_sdk/integrations/aiohttp.py
@@ -58,15 +58,10 @@ def __init__(self, transaction_style="handler_name"):
             )
         self.transaction_style = transaction_style
 
-    @staticmethod
-    def setup_once():
+    def setup_once(self):
         # type: () -> None
 
-        try:
-            version = tuple(map(int, AIOHTTP_VERSION.split(".")[:2]))
-        except (TypeError, ValueError):
-            raise DidNotEnable("AIOHTTP version unparsable: {}".format(AIOHTTP_VERSION))
-
+        version = self.parse_version(AIOHTTP_VERSION)
         if version < (3, 4):
             raise DidNotEnable("AIOHTTP 3.4 or newer required.")
 
diff --git a/sentry_sdk/integrations/boto3.py b/sentry_sdk/integrations/boto3.py
index e65f5a754b..8f6e0dc58b 100644
--- a/sentry_sdk/integrations/boto3.py
+++ b/sentry_sdk/integrations/boto3.py
@@ -25,15 +25,9 @@
 class Boto3Integration(Integration):
     identifier = "boto3"
 
-    @staticmethod
-    def setup_once():
+    def setup_once(self):
         # type: () -> None
-        try:
-            version = tuple(map(int, BOTOCORE_VERSION.split(".")[:3]))
-        except (ValueError, TypeError):
-            raise DidNotEnable(
-                "Unparsable botocore version: {}".format(BOTOCORE_VERSION)
-            )
+        version = self.parse_version(BOTOCORE_VERSION)
         if version < (1, 12):
             raise DidNotEnable("Botocore 1.12 or newer is required.")
         orig_init = BaseClient.__init__
diff --git a/sentry_sdk/integrations/bottle.py b/sentry_sdk/integrations/bottle.py
index 4fa077e8f6..ec15b6c780 100644
--- a/sentry_sdk/integrations/bottle.py
+++ b/sentry_sdk/integrations/bottle.py
@@ -52,15 +52,10 @@ def __init__(self, transaction_style="endpoint"):
             )
         self.transaction_style = transaction_style
 
-    @staticmethod
-    def setup_once():
+    def setup_once(self):
         # type: () -> None
 
-        try:
-            version = tuple(map(int, BOTTLE_VERSION.replace("-dev", "").split(".")))
-        except (TypeError, ValueError):
-            raise DidNotEnable("Unparsable Bottle version: {}".format(version))
-
+        version = self.parse_version(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 109862bd90..d9cbdd5ba1 100644
--- a/sentry_sdk/integrations/chalice.py
+++ b/sentry_sdk/integrations/chalice.py
@@ -94,13 +94,9 @@ def wrapped_view_function(**function_args):
 class ChaliceIntegration(Integration):
     identifier = "chalice"
 
-    @staticmethod
-    def setup_once():
+    def setup_once(self):
         # type: () -> None
-        try:
-            version = tuple(map(int, CHALICE_VERSION.split(".")[:3]))
-        except (ValueError, TypeError):
-            raise DidNotEnable("Unparsable Chalice version: {}".format(CHALICE_VERSION))
+        version = self.parse_version(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 8129fab46b..84bcf32864 100644
--- a/sentry_sdk/integrations/falcon.py
+++ b/sentry_sdk/integrations/falcon.py
@@ -98,14 +98,9 @@ def __init__(self, transaction_style="uri_template"):
             )
         self.transaction_style = transaction_style
 
-    @staticmethod
-    def setup_once():
+    def setup_once(self):
         # type: () -> None
-        try:
-            version = tuple(map(int, FALCON_VERSION.split(".")))
-        except (ValueError, TypeError):
-            raise DidNotEnable("Unparsable Falcon version: {}".format(FALCON_VERSION))
-
+        version = self.parse_version(FALCON_VERSION)
         if version < (1, 4):
             raise DidNotEnable("Falcon 1.4 or newer required.")
 
diff --git a/sentry_sdk/integrations/flask.py b/sentry_sdk/integrations/flask.py
index 8883cbb724..cfa92a66d1 100644
--- a/sentry_sdk/integrations/flask.py
+++ b/sentry_sdk/integrations/flask.py
@@ -64,20 +64,12 @@ def __init__(self, transaction_style="endpoint"):
             )
         self.transaction_style = transaction_style
 
-    @staticmethod
-    def setup_once():
+    def setup_once(self):
         # 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 = self.parse_version(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 f4c77d7df2..19ac970cfc 100644
--- a/sentry_sdk/integrations/rq.py
+++ b/sentry_sdk/integrations/rq.py
@@ -30,15 +30,9 @@
 class RqIntegration(Integration):
     identifier = "rq"
 
-    @staticmethod
-    def setup_once():
+    def setup_once(self):
         # type: () -> None
-
-        try:
-            version = tuple(map(int, RQ_VERSION.split(".")[:3]))
-        except (ValueError, TypeError):
-            raise DidNotEnable("Unparsable RQ version: {}".format(RQ_VERSION))
-
+        version = self.parse_version(RQ_VERSION)
         if version < (0, 6):
             raise DidNotEnable("RQ 0.6 or newer is required.")
 
diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py
index 4e20cc9ece..9200e2f338 100644
--- a/sentry_sdk/integrations/sanic.py
+++ b/sentry_sdk/integrations/sanic.py
@@ -52,16 +52,10 @@ class SanicIntegration(Integration):
     identifier = "sanic"
     version = (0, 0)  # type: Tuple[int, ...]
 
-    @staticmethod
-    def setup_once():
+    def setup_once(self):
         # type: () -> None
-
-        try:
-            SanicIntegration.version = tuple(map(int, SANIC_VERSION.split(".")))
-        except (TypeError, ValueError):
-            raise DidNotEnable("Unparsable Sanic version: {}".format(SANIC_VERSION))
-
-        if SanicIntegration.version < (0, 8):
+        self.version = self.parse_version(SANIC_VERSION)
+        if self.version < (0, 8):
             raise DidNotEnable("Sanic 0.8 or newer required.")
 
         if not HAS_REAL_CONTEXTVARS:
@@ -84,7 +78,7 @@ def setup_once():
             # https://github.com/huge-success/sanic/issues/1332
             ignore_logger("root")
 
-        if SanicIntegration.version < (21, 9):
+        if self.version < (21, 9):
             _setup_legacy_sanic()
             return
 
diff --git a/sentry_sdk/integrations/sqlalchemy.py b/sentry_sdk/integrations/sqlalchemy.py
index 4b0207f5ec..a76ed62cab 100644
--- a/sentry_sdk/integrations/sqlalchemy.py
+++ b/sentry_sdk/integrations/sqlalchemy.py
@@ -23,17 +23,10 @@
 class SqlalchemyIntegration(Integration):
     identifier = "sqlalchemy"
 
-    @staticmethod
-    def setup_once():
+    def setup_once(self):
         # type: () -> None
 
-        try:
-            version = tuple(map(int, SQLALCHEMY_VERSION.split("b")[0].split(".")))
-        except (TypeError, ValueError):
-            raise DidNotEnable(
-                "Unparsable SQLAlchemy version: {}".format(SQLALCHEMY_VERSION)
-            )
-
+        version = self.parse_version(SQLALCHEMY_VERSION)
         if version < (1, 2):
             raise DidNotEnable("SQLAlchemy 1.2 or newer required.")
 
diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py
index 5c590bcdfa..b5627988ea 100644
--- a/tests/integrations/aiohttp/test_aiohttp.py
+++ b/tests/integrations/aiohttp/test_aiohttp.py
@@ -261,3 +261,17 @@ async def kangaroo_handler(request):
             }
         )
     )
+
+
+def test_version_parsing():
+    integration = AioHttpIntegration()
+    # Testing version parser with various versions of Aiohttp
+    versions = [
+        ("3.8.1", (3, 8, 1)),
+        ("3.8.0a7", (3, 8, 0)),
+        ("3.7.4.post0", (3, 7, 4)),
+        ("3.6.1b4", (3, 6, 1)),
+        ("3.6.0", (3, 6, 0)),
+    ]
+    for _input, expected in versions:
+        assert integration.parse_version(_input) == expected
diff --git a/tests/integrations/boto3/test_s3.py b/tests/integrations/boto3/test_s3.py
index 67376b55d4..1a0e01479a 100644
--- a/tests/integrations/boto3/test_s3.py
+++ b/tests/integrations/boto3/test_s3.py
@@ -83,3 +83,14 @@ def test_streaming_close(sentry_init, capture_events):
     assert span1["op"] == "aws.request"
     span2 = event["spans"][1]
     assert span2["op"] == "aws.request.stream"
+
+
+def test_version_parsing():
+    integration = Boto3Integration()
+    # Testing version parser with various versions of Boto3
+    versions = [
+        ("1.21.8", (1, 21, 8)),
+        ("1.19.12", (1, 19, 12)),
+    ]
+    for _input, expected in versions:
+        assert integration.parse_version(_input) == expected
diff --git a/tests/integrations/bottle/test_bottle.py b/tests/integrations/bottle/test_bottle.py
index 16aacb55c5..f856c1adf5 100644
--- a/tests/integrations/bottle/test_bottle.py
+++ b/tests/integrations/bottle/test_bottle.py
@@ -196,7 +196,7 @@ def index():
     assert len(event["request"]["data"]["foo"]) == 512
 
 
-@pytest.mark.parametrize("input_char", [u"a", b"a"])
+@pytest.mark.parametrize("input_char", ["a", b"a"])
 def test_too_large_raw_request(
     sentry_init, input_char, capture_events, app, get_client
 ):
@@ -441,3 +441,14 @@ def here():
     client.get("/")
 
     assert not events
+
+
+def test_version_parsing():
+    integration = bottle_sentry.BottleIntegration()
+    # Testing version parser with various versions of bottle
+    versions = [
+        ("0.12.19", (0, 12, 19)),
+        ("0.10.11", (0, 10, 11)),
+    ]
+    for _input, expected in versions:
+        assert integration.parse_version(_input) == expected
diff --git a/tests/integrations/chalice/test_chalice.py b/tests/integrations/chalice/test_chalice.py
index 8bb33a5cb6..7c7a03af27 100644
--- a/tests/integrations/chalice/test_chalice.py
+++ b/tests/integrations/chalice/test_chalice.py
@@ -109,3 +109,14 @@ def test_bad_reques(client: RequestHandler) -> None:
             ("Message", "BadRequestError: bad-request"),
         ]
     )
+
+
+def test_version_parsing():
+    integration = ChaliceIntegration()
+    # Testing version parser with various versions of chalice
+    versions = [
+        ("1.26.6", (1, 26, 6)),
+        ("1.0.0b2", (1, 0, 0)),
+    ]
+    for _input, expected in versions:
+        assert integration.parse_version(_input) == expected
diff --git a/tests/integrations/falcon/test_falcon.py b/tests/integrations/falcon/test_falcon.py
index 84e8d228f0..a011327dd0 100644
--- a/tests/integrations/falcon/test_falcon.py
+++ b/tests/integrations/falcon/test_falcon.py
@@ -373,3 +373,11 @@ def generator():
 
     with sentry_sdk.configure_scope() as scope:
         assert not scope._tags["request_data"]
+
+
+def test_version_parsing():
+    integration = FalconIntegration()
+    # Testing version parser with various versions of falcon
+    versions = [("3.0.0", (3, 0, 0)), ("3.0.0rc3", (3, 0, 0)), ("2.0.0a2", (2, 0, 0))]
+    for _input, expected in versions:
+        assert integration.parse_version(_input) == expected
diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py
index 8723a35c86..349fafa89c 100644
--- a/tests/integrations/flask/test_flask.py
+++ b/tests/integrations/flask/test_flask.py
@@ -775,3 +775,17 @@ def index():
         response = client.get("/")
         assert response.status_code == 200
         assert response.data == b"hi"
+
+
+def test_version_parsing():
+    integration = flask_sentry.FlaskIntegration()
+    # Testing version parser with recent versions of Flask
+    versions = [
+        ("0.12.5", (0, 12, 5)),
+        ("1.0", (1, 0)),
+        ("1.1.4", (1, 1, 4)),
+        ("2.0.0rc1", (2, 0, 0)),
+        ("2.0.3", (2, 0, 3)),
+    ]
+    for _input, expected in versions:
+        assert integration.parse_version(_input) == expected
diff --git a/tests/integrations/rq/test_rq.py b/tests/integrations/rq/test_rq.py
index 651bf22248..e085fa8745 100644
--- a/tests/integrations/rq/test_rq.py
+++ b/tests/integrations/rq/test_rq.py
@@ -192,3 +192,14 @@ def test_job_with_retries(sentry_init, capture_events):
     worker.work(burst=True)
 
     assert len(events) == 1
+
+
+def test_version_parsing():
+    integration = RqIntegration()
+    # Testing version parser with various versions of Rq
+    versions = [
+        ("1.10.1", (1, 10, 1)),
+        ("0.3.13", (0, 3, 13)),
+    ]
+    for _input, expected in versions:
+        assert integration.parse_version(_input) == expected
diff --git a/tests/integrations/sanic/test_sanic.py b/tests/integrations/sanic/test_sanic.py
index b91f94bfe9..ddabd32e5a 100644
--- a/tests/integrations/sanic/test_sanic.py
+++ b/tests/integrations/sanic/test_sanic.py
@@ -245,3 +245,14 @@ async def runner():
 
     with configure_scope() as scope:
         assert not scope._tags
+
+
+def test_version_parsing():
+    integration = SanicIntegration()
+    # Testing version parser with various versions of Sanic
+    versions = [
+        ("21.12.1", (21, 12, 1)),
+        ("0.8.2", (0, 8, 2)),
+    ]
+    for _input, expected in versions:
+        assert integration.parse_version(_input) == expected
diff --git a/tests/integrations/sqlalchemy/test_sqlalchemy.py b/tests/integrations/sqlalchemy/test_sqlalchemy.py
index 421a72ebae..d2ef8d67b1 100644
--- a/tests/integrations/sqlalchemy/test_sqlalchemy.py
+++ b/tests/integrations/sqlalchemy/test_sqlalchemy.py
@@ -224,3 +224,11 @@ def processor(event, hint):
     assert event["_meta"]["message"] == {
         "": {"len": 522, "rem": [["!limit", "x", 509, 512]]}
     }
+
+
+def test_version_parsing():
+    integration = SqlalchemyIntegration()
+    # Testing version parser with various versions of SqlAlchemy
+    versions = [("1.4.31", (1, 4, 31)), ("1.4.0b1", (1, 4, 0))]
+    for _input, expected in versions:
+        assert integration.parse_version(_input) == expected