Skip to content

Commit

Permalink
Implement a PEP440 compliant version parser when integration needs to…
Browse files Browse the repository at this point in the history
… control lib/framework version (getsentry#1095)
  • Loading branch information
ohe committed Mar 11, 2022
1 parent a8f6af1 commit 0182759
Show file tree
Hide file tree
Showing 19 changed files with 169 additions and 76 deletions.
48 changes: 47 additions & 1 deletion sentry_sdk/integrations/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,6 +23,39 @@
_installed_integrations = set() # type: Set[str]


# Version Pattern as defined in PEP 440
VERSION_PATTERN = r"""
v?
(?:
(?:(?P<epoch>[0-9]+)!)? # epoch
(?P<release>[0-9]+(?:\.[0-9]+)*) # release segment
(?P<pre> # pre-release
[-_\.]?
(?P<pre_l>(a|b|c|rc|alpha|beta|pre|preview))
[-_\.]?
(?P<pre_n>[0-9]+)?
)?
(?P<post> # post release
(?:-(?P<post_n1>[0-9]+))
|
(?:
[-_\.]?
(?P<post_l>post|rev|r)
[-_\.]?
(?P<post_n2>[0-9]+)?
)
)?
(?P<dev> # dev release
[-_\.]?
(?P<dev_l>dev)
[-_\.]?
(?P<dev_n>[0-9]+)?
)?
)
(?:\+(?P<local>[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]]]

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
9 changes: 2 additions & 7 deletions sentry_sdk/integrations/aiohttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand Down
10 changes: 2 additions & 8 deletions sentry_sdk/integrations/boto3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand Down
9 changes: 2 additions & 7 deletions sentry_sdk/integrations/bottle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand Down
8 changes: 2 additions & 6 deletions sentry_sdk/integrations/chalice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 2 additions & 7 deletions sentry_sdk/integrations/falcon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand Down
16 changes: 4 additions & 12 deletions sentry_sdk/integrations/flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 2 additions & 8 deletions sentry_sdk/integrations/rq.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand Down
14 changes: 4 additions & 10 deletions sentry_sdk/integrations/sanic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
11 changes: 2 additions & 9 deletions sentry_sdk/integrations/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand Down
14 changes: 14 additions & 0 deletions tests/integrations/aiohttp/test_aiohttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions tests/integrations/boto3/test_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 12 additions & 1 deletion tests/integrations/bottle/test_bottle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down Expand Up @@ -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
11 changes: 11 additions & 0 deletions tests/integrations/chalice/test_chalice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions tests/integrations/falcon/test_falcon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions tests/integrations/flask/test_flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions tests/integrations/rq/test_rq.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions tests/integrations/sanic/test_sanic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions tests/integrations/sqlalchemy/test_sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 0182759

Please sign in to comment.