From 7a075c397d65a3901d74534ea55532b3137d6409 Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Mon, 5 Jun 2023 16:45:17 +0200 Subject: [PATCH 1/8] Better version parsing --- sentry_sdk/integrations/aiohttp.py | 9 ++--- sentry_sdk/integrations/arq.py | 9 +++-- sentry_sdk/integrations/boto3.py | 11 +++--- sentry_sdk/integrations/bottle.py | 9 ++--- sentry_sdk/integrations/chalice.py | 9 +++-- sentry_sdk/integrations/falcon.py | 8 +++-- sentry_sdk/integrations/flask.py | 18 +++++----- sentry_sdk/integrations/rq.py | 7 ++-- sentry_sdk/integrations/sanic.py | 7 ++-- sentry_sdk/integrations/sqlalchemy.py | 12 +++---- sentry_sdk/utils.py | 52 +++++++++++++++++++++++++++ tests/test_utils.py | 37 +++++++++++++++++++ 12 files changed, 145 insertions(+), 43 deletions(-) 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..1b648e9c48 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 @@ -57,9 +58,9 @@ class SanicIntegration(Integration): def setup_once(): # type: () -> None - try: - SanicIntegration.version = tuple(map(int, SANIC_VERSION.split("."))) - except (TypeError, ValueError): + version = parse_version(SANIC_VERSION) + + if version is None: raise DidNotEnable("Unparsable Sanic version: {}".format(SANIC_VERSION)) if SanicIntegration.version < (0, 8): 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..c0d64b79f9 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, int, Optional[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""" + 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"]
+        release_tuple = tuple(map(int, release.split(".")[:3]))
+    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

From c2adf2f3d44da41c13daaa9aa9052d016b345e10 Mon Sep 17 00:00:00 2001
From: Anton Pirker 
Date: Mon, 5 Jun 2023 16:49:38 +0200
Subject: [PATCH 2/8] Fixed linting

---
 sentry_sdk/utils.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py
index c0d64b79f9..b269510a7d 100644
--- a/sentry_sdk/utils.py
+++ b/sentry_sdk/utils.py
@@ -1476,7 +1476,7 @@ def parse_version(version):
     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"""
+    VERSION_PATTERN = r"""  # noqa: N806
         v?
         (?:
             (?:(?P[0-9]+)!)?                           # epoch

From 8356964b1551f7277fd1f5626e11e5ea9d451b30 Mon Sep 17 00:00:00 2001
From: Anton Pirker 
Date: Mon, 5 Jun 2023 17:04:15 +0200
Subject: [PATCH 3/8] Fixed Sanic version

---
 sentry_sdk/integrations/sanic.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py
index 1b648e9c48..8f42ee6297 100644
--- a/sentry_sdk/integrations/sanic.py
+++ b/sentry_sdk/integrations/sanic.py
@@ -58,9 +58,9 @@ class SanicIntegration(Integration):
     def setup_once():
         # type: () -> None
 
-        version = parse_version(SANIC_VERSION)
+        SanicIntegration.version = parse_version(SANIC_VERSION)
 
-        if version is None:
+        if SanicIntegration.version is None:
             raise DidNotEnable("Unparsable Sanic version: {}".format(SANIC_VERSION))
 
         if SanicIntegration.version < (0, 8):

From 617ff8e008010efafb64348790107e1474a350d2 Mon Sep 17 00:00:00 2001
From: Anton Pirker 
Date: Mon, 5 Jun 2023 17:11:57 +0200
Subject: [PATCH 4/8] Fixed linting

---
 sentry_sdk/integrations/sanic.py | 4 ++--
 sentry_sdk/utils.py              | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py
index 8f42ee6297..f6e46e76ef 100644
--- a/sentry_sdk/integrations/sanic.py
+++ b/sentry_sdk/integrations/sanic.py
@@ -52,7 +52,7 @@
 
 class SanicIntegration(Integration):
     identifier = "sanic"
-    version = (0, 0)  # type: Tuple[int, ...]
+    version = None
 
     @staticmethod
     def setup_once():
@@ -226,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):  # type: ignore
                 await _hub_exit(request)
 
     return sentry_wrapped_error_handler
diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py
index b269510a7d..b25d24e051 100644
--- a/sentry_sdk/utils.py
+++ b/sentry_sdk/utils.py
@@ -1470,7 +1470,7 @@ def match_regex_list(item, regex_list=None, substring_matching=False):
 
 
 def parse_version(version):
-    # type: (str) -> Optional[Tuple[int, int, Optional[int]]]
+    # type: (str) -> Optional[Tuple[int, Optional[int], Optional[int]]]
     """
     Parses a version string into a tuple of integers.
     This uses the parsing loging from PEP 440:

From b8104345d912a50d7dbfdb32bfc6b45f27730ae2 Mon Sep 17 00:00:00 2001
From: Anton Pirker 
Date: Mon, 5 Jun 2023 17:15:27 +0200
Subject: [PATCH 5/8] fixed typing

---
 sentry_sdk/utils.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py
index b25d24e051..0cff3e8cd7 100644
--- a/sentry_sdk/utils.py
+++ b/sentry_sdk/utils.py
@@ -1513,7 +1513,7 @@ def parse_version(version):
     )
 
     try:
-        release = pattern.match(version).groupdict()["release"]
+        release = pattern.match(version).groupdict()["release"]  # type: ignore
         release_tuple = tuple(map(int, release.split(".")[:3]))
     except (TypeError, ValueError, AttributeError):
         return None

From e7fdcd6f9a161eac73abcbe97f6999724c3f983f Mon Sep 17 00:00:00 2001
From: Anton Pirker 
Date: Mon, 5 Jun 2023 17:20:20 +0200
Subject: [PATCH 6/8] Fixed typing

---
 sentry_sdk/utils.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py
index 0cff3e8cd7..8b7cf9d764 100644
--- a/sentry_sdk/utils.py
+++ b/sentry_sdk/utils.py
@@ -1514,7 +1514,9 @@ def parse_version(version):
 
     try:
         release = pattern.match(version).groupdict()["release"]  # type: ignore
-        release_tuple = tuple(map(int, release.split(".")[:3]))
+        release_tuple = tuple(
+            map(int, release.split(".")[:3])
+        )  # type: Tuple[int, Optional[int], Optional[int]]
     except (TypeError, ValueError, AttributeError):
         return None
 

From 2023e1d87f118c573150771147372efe110f9ca6 Mon Sep 17 00:00:00 2001
From: Anton Pirker 
Date: Tue, 6 Jun 2023 08:28:33 +0200
Subject: [PATCH 7/8] typing again

---
 sentry_sdk/utils.py | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py
index 8b7cf9d764..fa9ae15be9 100644
--- a/sentry_sdk/utils.py
+++ b/sentry_sdk/utils.py
@@ -1470,7 +1470,7 @@ def match_regex_list(item, regex_list=None, substring_matching=False):
 
 
 def parse_version(version):
-    # type: (str) -> Optional[Tuple[int, Optional[int], Optional[int]]]
+    # type: (str) -> Optional[Tuple[int, ...]]
     """
     Parses a version string into a tuple of integers.
     This uses the parsing loging from PEP 440:
@@ -1514,9 +1514,7 @@ def parse_version(version):
 
     try:
         release = pattern.match(version).groupdict()["release"]  # type: ignore
-        release_tuple = tuple(
-            map(int, release.split(".")[:3])
-        )  # type: Tuple[int, Optional[int], Optional[int]]
+        release_tuple = tuple(map(int, release.split(".")[:3]))  # type: Tuple[int, ...]
     except (TypeError, ValueError, AttributeError):
         return None
 

From b16bc57be562ad104587cc0b93478db2ce22bfa8 Mon Sep 17 00:00:00 2001
From: Anton Pirker 
Date: Tue, 6 Jun 2023 09:21:00 +0200
Subject: [PATCH 8/8] typing

---
 sentry_sdk/integrations/sanic.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py
index f6e46e76ef..f9474d6bb6 100644
--- a/sentry_sdk/integrations/sanic.py
+++ b/sentry_sdk/integrations/sanic.py
@@ -226,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 and SanicIntegration.version == (21, 9):  # type: ignore
+            if SanicIntegration.version and SanicIntegration.version == (21, 9):
                 await _hub_exit(request)
 
     return sentry_wrapped_error_handler